[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: jpochyla\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"\"\nlabels: bug\nassignees: \"\"\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior.\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Environment**\n\n- OS:\n- Version:\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\npermissions:\n  contents: read\n\nenv:\n  CARGO_TERM_COLOR: always\n  VERSION_DATE: ${{ format('{0}.{1}.{2}', github.run_number, github.run_id, github.run_attempt) }}\n  VERSION_TIMESTAMP: ${{ github.event.repository.updated_at }}\n\njobs:\n  code-style:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n\n      - name: Setup Cache\n        uses: Swatinem/rust-cache@v2\n\n      - name: Install Linux Dependencies\n        if: runner.os == 'Linux'\n        run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libssl-dev libasound2-dev\n\n      - name: Check Formatting\n        run: cargo clippy -- -D warnings\n\n  build:\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            target: x86_64-unknown-linux-gnu\n          - os: ubuntu-latest\n            target: aarch64-unknown-linux-gnu\n          - os: macos-latest\n          - os: windows-latest\n\n    runs-on: ${{ matrix.os }}\n    env:\n      MACOSX_DEPLOYMENT_TARGET: 11.0\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n\n      - name: Set Version Info\n        id: version\n        shell: bash\n        env:\n          FULL_SHA: ${{ github.sha }}\n        run: |\n          echo \"BUILD_DATE=$(date +'%Y%m%d')\" >> $GITHUB_ENV\n          echo \"VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')\" >> $GITHUB_ENV\n          SHORT_SHA=${FULL_SHA::7}\n          echo \"RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA\" >> $GITHUB_ENV\n\n      - name: Setup Rust Cache\n        uses: Swatinem/rust-cache@v2\n        with:\n          key: ${{ hashFiles('Cross.toml') }}\n\n      - name: Install Cross\n        if: runner.os == 'Linux'\n        run: cargo install cross\n\n      - name: Build (Linux)\n        if: runner.os == 'Linux'\n        run: cross build --release --target ${{ matrix.target }}\n\n      - name: Build Release (macOS)\n        if: runner.os == 'macOS'\n        run: |\n          rustup target add x86_64-apple-darwin aarch64-apple-darwin\n          cargo build --release --target x86_64-apple-darwin --target aarch64-apple-darwin\n\n      - name: Build Release (Windows)\n        if: runner.os == 'Windows'\n        run: cargo build --release\n\n      - name: Cache cargo-bundle and Homebrew\n        id: cache-tools\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/bin/cargo-bundle\n            ~/Library/Caches/Homebrew\n          key: ${{ runner.os }}-tools-${{ hashFiles('**/Cargo.lock', '.github/workflows/build.yml') }}\n          restore-keys: |\n            ${{ runner.os }}-tools-\n\n      - name: Install cargo-bundle\n        if: runner.os == 'macOS' && !steps.cache-tools.outputs.cache-hit\n        run: cargo install cargo-bundle\n\n      - name: Install create-dmg\n        if: runner.os == 'macOS'\n        run: brew install -q create-dmg\n\n      - name: Create macOS universal binary\n        if: runner.os == 'macOS'\n        run: |\n          mkdir -p target/release\n          lipo -create \\\n            -arch x86_64 target/x86_64-apple-darwin/release/psst-gui \\\n            -arch arm64 target/aarch64-apple-darwin/release/psst-gui \\\n            -output target/release/psst-gui\n\n      - name: Bundle macOS Release\n        if: runner.os == 'macOS'\n        env:\n          CARGO_BUNDLE_SKIP_BUILD: \"true\"\n        run: |\n          cargo bundle --release -p psst-gui\n\n      - name: Create DMG\n        if: runner.os == 'macOS'\n        run: |\n          create-dmg \\\n            --volname \"Psst\" \\\n            --volicon \"psst-gui/assets/logo.icns\" \\\n            --window-pos 200 120 \\\n            --window-size 600 400 \\\n            --icon-size 100 \\\n            --icon \"Psst.app\" 150 160 \\\n            --hide-extension \"Psst.app\" \\\n            --app-drop-link 450 160 \\\n            \"Psst.dmg\" \\\n            \"target/release/bundle/osx/Psst.app\"\n\n      - name: Upload macOS DMG\n        uses: actions/upload-artifact@v4\n        if: runner.os == 'macOS'\n        with:\n          name: Psst.dmg\n          path: Psst.dmg\n\n      - name: Make Linux Binary Executable\n        if: runner.os == 'Linux'\n        run: chmod +x target/${{ matrix.target }}/release/psst-gui\n\n      - name: Rename Linux Binary\n        if: runner.os == 'Linux'\n        run: mv target/${{ matrix.target }}/release/psst-gui target/${{ matrix.target }}/release/psst\n\n      - name: Upload Linux Binary\n        uses: actions/upload-artifact@v4\n        if: runner.os == 'Linux'\n        with:\n          name: psst-${{ matrix.target }}\n          path: target/${{ matrix.target }}/release/psst\n\n      - name: Upload Windows Executable\n        uses: actions/upload-artifact@v4\n        if: runner.os == 'Windows'\n        with:\n          name: Psst.exe\n          path: target/release/psst-gui.exe\n\n  deb:\n    runs-on: ubuntu-latest\n    needs: build\n    strategy:\n      matrix:\n        include:\n          - arch: amd64\n            target: x86_64-unknown-linux-gnu\n          - arch: arm64\n            target: aarch64-unknown-linux-gnu\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0 # Get full history to count number of commits for package version\n\n      - name: Download Linux Binaries\n        uses: actions/download-artifact@v4\n        with:\n          name: psst-${{ matrix.target }}\n          path: binaries\n\n      - name: Move Binary\n        run: |\n          mkdir -p pkg/usr/bin/\n          mv binaries/psst pkg/usr/bin/\n\n      - name: Move Desktop Entry\n        run: mkdir -p pkg/usr/share/applications/; mv .pkg/psst.desktop pkg/usr/share/applications/\n\n      - name: Add Icons\n        run: |\n          LOGOS=$(cd ./psst-gui/assets/ && ls logo_*.png)\n          for LOGO in $LOGOS\n          do\n            LOGO_SIZE=$(echo \"${LOGO}\" | grep -oE '[[:digit:]]{2,}')\n            mkdir -p \"pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/\"\n            cp \"./psst-gui/assets/${LOGO}\" \"pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/psst.png\"\n          done\n          mkdir -p \"pkg/usr/share/icons/hicolor/scalable/apps/\"\n          cp \"./psst-gui/assets/logo.svg\" \"pkg/usr/share/icons/hicolor/scalable/apps/psst.svg\"\n\n      - name: Set Permissions\n        run: chmod 755 pkg/usr/bin/psst\n\n      - name: Move License\n        run: mkdir -p pkg/usr/share/doc/psst-gui/; mv .pkg/copyright pkg/usr/share/doc/psst-gui/\n\n      - name: Write Package Config\n        run: |\n          mkdir -p pkg/DEBIAN\n          export ARCHITECTURE=${{ matrix.arch }}\n          SANITIZED_BRANCH=\"$(echo ${GITHUB_HEAD_REF:+.$GITHUB_HEAD_REF}|tr '_/' '-')\"\n          export VERSION=0.1.0\"$SANITIZED_BRANCH\"+r\"$(git rev-list --count HEAD)\"-0\n          envsubst < .pkg/DEBIAN/control > pkg/DEBIAN/control\n\n      - name: Build Package\n        run: |\n          cat pkg/DEBIAN/control\n          dpkg-deb -b pkg/ psst-${{ matrix.arch }}.deb\n\n      - name: Upload Debian Package\n        uses: actions/upload-artifact@v4\n        with:\n          name: psst-deb-${{ matrix.arch }}\n          path: \"*.deb\"\n\n  appimage:\n    if: false # Disable temporarily: https://github.com/jpochyla/psst/actions/runs/3897410142/jobs/6655282029\n    runs-on: ubuntu-latest\n    needs: deb\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v4\n\n      - name: Download Debian Package\n        uses: actions/download-artifact@v4\n        with:\n          name: psst-deb\n          # Downloads to the root of the workspace by default if path is omitted or '.',\n          # so removing explicit path to ${{github.workspace}}\n\n      - name: Install Dependencies\n        run: sudo apt-get update && sudo apt-get install -y libfuse2\n\n      - name: Create Workspace\n        run: mkdir -p appimage\n\n      - name: Download the Latest pkg2appimage\n        run: |\n          latest_release_appimage_url=$(wget -q https://api.github.com/repos/AppImageCommunity/pkg2appimage/releases/latest -O - | jq -r '.assets[0].browser_download_url')\n          wget --directory-prefix=appimage -c $latest_release_appimage_url\n\n      - name: Create Path to pkg2appimage\n        run: |\n          pkg2appimage_executable=$(ls appimage)\n          app_path=appimage/${pkg2appimage_executable}\n          chmod +x ${app_path}\n          echo \"app_path=${app_path}\" >> $GITHUB_ENV\n\n      - name: Create Path to pkg2appimage's Recipe File\n        run: |\n          recipe_path=psst/.pkg/APPIMAGE/pkg2appimage-ingredients.yml\n          echo \"recipe_path=${recipe_path}\" >> $GITHUB_ENV\n\n      - name: Run pkg2appimage\n        run: |\n          ${{env.app_path}} ${{env.recipe_path}}\n\n      - name: Upload AppImage\n        uses: actions/upload-artifact@v4\n        with:\n          name: psst-appimage\n          path: out/*.AppImage\n\n  release:\n    needs: [build, deb]\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    if: github.ref == 'refs/heads/main' && github.event_name == 'push'\n    steps:\n      - name: Set Version Info and Paths\n        id: set_paths\n        env:\n          FULL_SHA: ${{ github.sha }}\n        run: |\n          echo \"BUILD_DATE=$(date +'%Y%m%d')\" >> $GITHUB_ENV\n          echo \"VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')\" >> $GITHUB_ENV\n          SHORT_SHA=${FULL_SHA::7}\n          echo \"RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA\" >> $GITHUB_ENV\n\n      - name: Download All Artifacts\n        uses: actions/download-artifact@v4\n        with:\n          path: artifacts\n\n      - name: Prepare Release Body Data\n        id: release_data\n        run: |\n          echo \"CURRENT_DATE_STR=$(date)\" >> $GITHUB_ENV\n\n      - name: Prepare Release Assets\n        id: prep_assets\n        run: |\n          set -e\n          mkdir -p artifacts_final\n\n          find artifacts -type f -name 'Psst.dmg' -exec mv {} artifacts_final/Psst.dmg \\;\n          find artifacts -type f -name 'psst-gui.exe' -exec mv {} artifacts_final/Psst.exe \\;\n          find artifacts -type f -name 'psst-amd64.deb' -exec mv {} artifacts_final/psst-amd64.deb \\;\n          find artifacts -type f -name 'psst-arm64.deb' -exec mv {} artifacts_final/psst-arm64.deb \\;\n\n          find artifacts -type f -name 'psst' -path '*/psst-x86_64-unknown-linux-gnu/*' -exec mv {} artifacts_final/psst-linux-x86_64 \\;\n          find artifacts -type f -name 'psst' -path '*/psst-aarch64-unknown-linux-gnu/*' -exec mv {} artifacts_final/psst-linux-aarch64 \\;\n\n          rm -rf artifacts\n          mv artifacts_final artifacts\n          ls -l artifacts/\n\n      - name: Create Main Release\n        uses: softprops/action-gh-release@v2\n        with:\n          name: Continuous release (${{ env.RELEASE_VERSION }})\n          tag_name: rolling\n          make_latest: true\n          prerelease: false\n          body: |\n            This is a rolling release of Psst, published automatically on every commit to main.\n\n            Version: ${{ env.RELEASE_VERSION }}\n            Commit: ${{ github.sha }}\n            Built: ${{ env.CURRENT_DATE_STR }}\n            Workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n            See the release assets for SHA256 checksums.\n          files: artifacts/*\n          generate_release_notes: false\n"
  },
  {
    "path": ".gitignore",
    "content": "config.json\ntarget\ncache\n.cargo\n.idea\n.DS_Store\n.env\n*.iml\nrust-toolchain\n*.ico"
  },
  {
    "path": ".homebrew/generate_formula.sh",
    "content": "#!/bin/bash\n\nset -eo pipefail\n\nREPO_OWNER=\"jpochyla\"\nREPO_NAME=\"psst\"\n\ncat <<EOF\ncask \"psst\" do\n  version :latest\n  sha256 :no_check\n\n  url \"https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest/download/Psst.dmg\"\n  name \"Psst\"\n  desc \"Fast and native Spotify client\"\n  homepage \"https://github.com/${REPO_OWNER}/${REPO_NAME}/\"\n\n  depends_on macos: \">= :big_sur\"\n\n  app \"Psst.app\"\n\n  zap trash: [\n    \"~/Library/Application Support/Psst\",\n    \"~/Library/Caches/com.jpochyla.psst\",\n    \"~/Library/Caches/Psst\",\n    \"~/Library/HTTPStorages/com.jpochyla.psst\",\n    \"~/Library/Preferences/com.jpochyla.psst.plist\",\n    \"~/Library/Saved Application State/com.jpochyla.psst.savedState\",\n  ]\nend\nEOF\n"
  },
  {
    "path": ".pkg/APPIMAGE/pkg2appimage-ingredients.yml",
    "content": "ingredients:\n  dist: focal\n  sources:\n    - deb http://us.archive.ubuntu.com/ubuntu/ focal main universe\n  debs:\n    - ../*.deb\n  script:\n    - mkdir -p /home/runner/work/psst/psst/.AppDir/\n    - cp /home/runner/work/psst/psst/psst-gui/assets/logo_256.png /home/runner/work/psst/psst/.AppDir/psst.png\n"
  },
  {
    "path": ".pkg/DEBIAN/control",
    "content": "Package: psst-gui\nVersion: $VERSION\nArchitecture: $ARCHITECTURE\nMaintainer: Jan Pochyla <jpochyla@gmail.com>\nSection: sound\nPriority: optional\nHomepage: https://github.com/jpochyla/psst\nPackage-Type: deb\nDepends: libssl3 | libssl1.1, libgtk-3-0, libcairo2\nDescription: Fast and native Spotify client\n"
  },
  {
    "path": ".pkg/copyright",
    "content": "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: Psst\nSource: https://github.com/jpochyla/psst\n\nFiles: *\nCopyright: (c) 2020 Jan Pochyla\nLicense: MIT\n\nLicense: MIT\n 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:\n The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n 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.\n"
  },
  {
    "path": ".pkg/psst.desktop",
    "content": "[Desktop Entry]\nType=Application\nName=Psst\nComment=Fast and native Spotify client\nGenericName=Music Player\nIcon=psst\nTryExec=psst-gui\nExec=psst-gui %U\nTerminal=false\nMimeType=x-scheme-handler/psst;\nCategories=Audio;Music;Player;AudioVideo;\nStartupWMClass=psst-gui\n"
  },
  {
    "path": ".rustfmt.toml",
    "content": "imports_granularity = \"Crate\"\nwrap_comments = true\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nresolver = \"2\"\nmembers = [\"psst-core\", \"psst-cli\", \"psst-gui\"]\n\n[profile.dev]\nopt-level = 1\ndebug = true\nlto = false\n\n[profile.release]\nopt-level = 3\nstrip = true\nlto = true\ncodegen-units = 1\n\n[profile.dev.package.symphonia]\nopt-level = 2\n\n[profile.dev.package.libsamplerate]\nopt-level = 2\n"
  },
  {
    "path": "Cross.toml",
    "content": "[build]\npre-build = [\"\"\"\n    dpkg --add-architecture $CROSS_DEB_ARCH && \\\n    apt-get update && \\\n    apt-get --assume-yes install \\\n        libgtk-3-dev:$CROSS_DEB_ARCH \\\n        libssl-dev:$CROSS_DEB_ARCH \\\n        libasound2-dev:$CROSS_DEB_ARCH\n\"\"\"]\n\n[target.x86_64-unknown-linux-gnu]\nimage = \"ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge\"\n\n[target.aarch64-unknown-linux-gnu]\nimage = \"ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge\"\n"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) 2020 Jan Pochyla\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Psst\n\nA fast Spotify client with a native GUI written in Rust, without Electron.\nPsst is still very early in development, lacking in features, stability, and general user experience.\nIt's fully cross-platform, supporting Windows, Linux, and macOS.\nContributions are welcome!\n\n**Note:** A Spotify Premium account is required.\n\n[![Build](https://github.com/jpochyla/psst/actions/workflows/build.yml/badge.svg)](https://github.com/jpochyla/psst/actions)\n\n![Screenshot](./psst-gui/assets/screenshot.png)\n\n## Download\n\nGitHub Actions automatically builds and releases new versions when changes are pushed to the `main` branch.\nYou can download the latest release for Windows, Linux, and macOS from the [GitHub Releases page](https://github.com/jpochyla/psst/releases/latest).\n\n| Platform               | Download Link                                                                            |\n| ---------------------- | ---------------------------------------------------------------------------------------- |\n| Linux (x86_64)         | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-linux-x86_64)  |\n| Linux (aarch64)        | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-linux-aarch64) |\n| Debian Package (amd64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-amd64.deb)     |\n| Debian Package (arm64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-arm64.deb)     |\n| macOS                  | [Download](https://github.com/jpochyla/psst/releases/latest/download/Psst.dmg)           |\n| Windows                | [Download](https://github.com/jpochyla/psst/releases/latest/download/Psst.exe)           |\n\nUnofficial builds of Psst are also available through the [AUR](https://aur.archlinux.org/packages/psst-git) and [Homebrew](https://formulae.brew.sh/cask/psst).\n\n## Building\n\nOn all platforms, the **latest [Rust](https://rustup.rs/) stable** (at least 1.65.0) is required.\nFor platform-specific requirements, see the dropdowns below.\n\n<details>\n<summary>Linux</summary>\n\nOur user-interface library, Druid, has two possible backends on Linux: GTK and pure X11, with a Wayland backend in the works.\nThe default Linux backend is GTK.\nBefore building on Linux, make sure the required dependencies are installed.\n\n### Debian/Ubuntu\n\n```shell\nsudo apt-get install libssl-dev libgtk-3-dev libcairo2-dev libasound2-dev\n```\n\n### RHEL/Fedora\n\n```shell\nsudo dnf install openssl-devel gtk3-devel cairo-devel alsa-lib-devel\n```\n\n</details>\n\n<details>\n<summary>OpenBSD (WIP)</summary>\n\nOpenBSD support is still a WIP, and things will likely not function as intended.\nSimilar to Linux, Druid defaults to GTK while also providing a pure X11 backend.\nFurthermore, bindgen must be able to find LLVM through the expected environment variable.\nOnly OpenBSD/amd64 has been tested so far.\n\n```shell\ndoas pkg_add gtk+3 cairo llvm\nexport LIBCLANG_PATH=/usr/local/lib\n```\n\nIn case rustc(1) fails building bigger crates\n\n```shell\nmemory allocation of xxxx bytes failed\nerror: could not compile `gtk`\nCaused by:\n  process didn't exit successfully: `rustc --crate-name gtk [...]` (signal: 6, SIGABRT: process abort signal)\nwarning: build failed, waiting for other jobs to finish...\n```\n\ntry increasing your user's maximum heap size:\n\n```shell\nulimit -d $(( 2 * `ulimit -d` ))\n```\n\n</details>\n\n---\n\n#### Build from Source\n\n```shell\ncargo build\n# Append `--release` for a release build.\n```\n\n#### Run from Source\n\n```shell\ncargo run --bin psst-gui\n# Append `--release` for a release build.\n```\n\n#### Build Installation Bundle (i.e., macOS .app)\n\n```shell\ncargo install cargo-bundle\ncargo bundle --release\n```\n\n## Roadmap\n\n- [x] Vorbis track playback\n- [x] Browsing saved albums and tracks\n- [x] Save / unsave albums and tracks\n- [x] Browsing followed playlists\n- [x] Search for artists, albums, and tracks\n- [x] Podcast support\n- [x] Media keys control\n- [x] Open Spotify links through the search bar\n- [x] Audio volume control\n- [x] Audio loudness normalization\n- [x] Genre playlists and \"For You\" content\n- [x] Dark theme\n- [x] Credits support\n- [ ] Resilience to network errors (automatically retry timed-out requests)\n- [ ] Managing playlists\n  - Follow/unfollow\n  - Add/remove tracks\n  - Reorder tracks\n  - Rename playlist\n  - Playlist folders\n- [x] Playback queue\n- [ ] React to audio output device events\n  - Pause after disconnecting headphones\n  - Transfer playback after connecting headphones\n- [ ] Better caching\n  - Cache as many WebAPI responses as possible\n  - Visualize cache utilization\n    - Total cache usage in the config dialog\n    - Show time origin of cached data, allow to refresh\n- [ ] Trivia on the artist page, Wikipedia links\n- [ ] Downloading encrypted tracks\n- [ ] Reporting played tracks to Spotify servers\n- [ ] OS-specific application bundles\n- UI\n  - [ ] Rethink the current design, consider a two-pane layout\n    - Left pane for browsing\n    - Right pane for current playback\n  - [ ] Detect light/dark OS theme\n  - [ ] Robust error states, ideally with a retry button\n  - [ ] Correct playback highlight\n    - Highlight now-playing track only in the correct album/playlist\n    - Keep highlighted track in viewport\n  - [ ] Paging or virtualized lists for albums and tracks\n  - [ ] Grid for albums and artists\n  - [ ] Robust active/inactive menu visualization\n  - [ ] Save playback state\n\n## Development\n\nContributions are very welcome!  \nHere's the basic project structure:\n\n- `/psst-core` - Core library, takes care of Spotify TCP session, audio file retrieval, decoding, audio output, playback queue, etc.\n- `/psst-gui` - GUI application built with [Druid](https://github.com/linebender/druid)\n- `/psst-cli` - Example CLI that plays a track. Credentials must be configured in the code.\n\n## Privacy Policy\n\nPsst connects only to the official Spotify servers and does not call home.\nCaches of various things are stored locally and can be deleted anytime.\nUser credentials are not stored at all; instead, a re-usable authentication token from Spotify is used.\n\n## Thanks\n\nThis project would not exist without the following:\n\n- Big thank you to [`librespot`](https://github.com/librespot-org/librespot), the Open Source Spotify client library for Rust. Most of `psst-core` is directly inspired by the ideas and code of `librespot`, although with a few differences:\n  - Spotify Connect (remote control) is not supported yet.\n  - Psst is completely synchronous, without `tokio` or other `async` runtime, although it will probably change in the future.\n  - Psst is using HTTPS-based CDN audio file retrieval, similar to the official Web client or [`librespot-java`](https://github.com/librespot-org/librespot-java), instead of the channel-based approach in `librespot`.\n- [`druid`](https://github.com/linebender/druid) native GUI library for Rust.\n- [`ncspot`](https://github.com/hrkfdn/ncspot) cross-platform ncurses Spotify client written in Rust, using `librespot`.\n- ...and of course other libraries and projects.\n"
  },
  {
    "path": "psst-cli/Cargo.toml",
    "content": "[package]\nname = \"psst-cli\"\nversion = \"0.1.0\"\nauthors = [\"Jan Pochyla <jpochyla@gmail.com>\"]\nedition = \"2021\"\n\n[features]\ndefault = [\"cpal\"]\ncpal = [\"psst-core/cpal\"]\ncubeb = [\"psst-core/cubeb\"]\n\n[dependencies]\npsst-core = { path = \"../psst-core\" }\n\nenv_logger = \"0.11.5\"\nlog = \"0.4.22\"\n"
  },
  {
    "path": "psst-cli/src/main.rs",
    "content": "use psst_core::{\n    audio::{\n        normalize::NormalizationLevel,\n        output::{AudioOutput, AudioSink, DefaultAudioOutput},\n    },\n    cache::{Cache, CacheHandle},\n    cdn::{Cdn, CdnHandle},\n    connection::Credentials,\n    error::Error,\n    item_id::{ItemId, ItemIdType},\n    player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent},\n    session::{SessionConfig, SessionService},\n};\nuse std::{env, io, io::BufRead, path::PathBuf, thread};\n\nfn main() {\n    env_logger::init();\n\n    let args: Vec<String> = env::args().collect();\n    let track_id = args\n        .get(1)\n        .expect(\"Expected <track_id> in the first parameter\");\n    let login_creds = Credentials::from_username_and_password(\n        env::var(\"SPOTIFY_USERNAME\").unwrap(),\n        env::var(\"SPOTIFY_PASSWORD\").unwrap(),\n    );\n    let session = SessionService::with_config(SessionConfig {\n        login_creds,\n        proxy_url: None,\n    });\n\n    start(track_id, session).unwrap();\n}\n\nfn start(track_id: &str, session: SessionService) -> Result<(), Error> {\n    let cdn = Cdn::new(session.clone(), None)?;\n    let cache = Cache::new(PathBuf::from(\"cache\"))?;\n    let item_id = ItemId::from_base62(track_id, ItemIdType::Track).unwrap();\n    play_item(\n        session,\n        cdn,\n        cache,\n        PlaybackItem {\n            item_id,\n            norm_level: NormalizationLevel::Track,\n        },\n    )\n}\n\nfn play_item(\n    session: SessionService,\n    cdn: CdnHandle,\n    cache: CacheHandle,\n    item: PlaybackItem,\n) -> Result<(), Error> {\n    let output = DefaultAudioOutput::open()?;\n    let config = PlaybackConfig::default();\n\n    let mut player = Player::new(session, cdn, cache, config, &output);\n\n    let _ui_thread = thread::spawn({\n        let player_sender = player.sender();\n\n        player_sender\n            .send(PlayerEvent::Command(PlayerCommand::LoadQueue {\n                items: vec![item, item, item],\n                position: 0,\n            }))\n            .unwrap();\n\n        move || {\n            for line in io::stdin().lock().lines() {\n                match line.as_ref().map(|s| s.as_str()) {\n                    Ok(\"p\") => {\n                        player_sender\n                            .send(PlayerEvent::Command(PlayerCommand::Pause))\n                            .unwrap();\n                    }\n                    Ok(\"r\") => {\n                        player_sender\n                            .send(PlayerEvent::Command(PlayerCommand::Resume))\n                            .unwrap();\n                    }\n                    Ok(\"s\") => {\n                        player_sender\n                            .send(PlayerEvent::Command(PlayerCommand::Stop))\n                            .unwrap();\n                    }\n                    Ok(\"<\") => {\n                        player_sender\n                            .send(PlayerEvent::Command(PlayerCommand::Previous))\n                            .unwrap();\n                    }\n                    Ok(\">\") => {\n                        player_sender\n                            .send(PlayerEvent::Command(PlayerCommand::Next))\n                            .unwrap();\n                    }\n                    _ => log::warn!(\"unknown command\"),\n                }\n            }\n        }\n    });\n\n    for event in player.receiver() {\n        player.handle(event);\n    }\n    output.sink().close();\n\n    Ok(())\n}\n"
  },
  {
    "path": "psst-core/Cargo.toml",
    "content": "[package]\nname = \"psst-core\"\nversion = \"0.1.0\"\nauthors = [\"Jan Pochyla <jpochyla@gmail.com>\"]\nedition = \"2021\"\n\n\n[build-dependencies]\ngix-config = \"0.45.1\"\ntime = { version = \"0.3.36\", features = [\"local-offset\"] }\n\n[dependencies]\n\n# Common\nbyteorder = { version = \"1.5.0\" }\ncrossbeam-channel = { version = \"0.5.13\" }\ngit-version = { version = \"0.3.9\" }\nlog = { version = \"0.4.22\" }\nnum-bigint = { version = \"0.4.6\", features = [\"rand\"] }\nnum-traits = { version = \"0.2.19\" }\noauth2 = { version = \"4.4.2\" }\nparking_lot = { version = \"0.12.3\" }\nlibrespot-protocol = \"0.7.1\"\nprotobuf = \"3\"\nsysinfo = \"0.35.0\"\ndata-encoding = \"2.9\"\nrand = { version = \"0.9.1\" }\nrangemap = { version = \"1.5.1\" }\nserde = { version = \"1.0.210\", features = [\"derive\"] }\nserde_json = { version = \"1.0.132\" }\nsocks = { version = \"0.3.4\" }\ntempfile = { version = \"3.13.0\" }\nrustfm-scrobble = \"1.1.1\"\nureq = { version = \"3.0.11\", features = [\"json\"] }\nurl = { version = \"2.5.2\" }\n\n# Cryptography\naes = { version = \"0.8.4\" }\nctr = { version = \"0.9.2\" }\nhmac = { version = \"0.12.1\" }\nsha-1 = { version = \"0.10.1\" }\nshannon = { version = \"0.2.0\" }\n\n# Audio\naudio_thread_priority = \"0.33.0\"\ncpal = { version = \"0.15.3\", optional = true }\ncubeb = { git = \"https://github.com/mozilla/cubeb-rs\", optional = true }\nlibsamplerate = { version = \"0.1.0\" }\nrb = { version = \"0.4.1\" }\nsymphonia = { version = \"0.5.4\", default-features = false, features = [\n  \"ogg\",\n  \"vorbis\",\n  \"mp3\",\n] }\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nwindows = { version = \"0.61.1\", features = [\"Win32_System_Com\"], default-features = false }\n"
  },
  {
    "path": "psst-core/build.rs",
    "content": "use gix_config::File;\nuse std::{env, fs, io::Write};\nuse time::OffsetDateTime;\n\nfn main() {\n    let outdir = env::var(\"OUT_DIR\").unwrap();\n    let outfile = format!(\"{outdir}/build-time.txt\");\n\n    let mut fh = fs::File::create(outfile).unwrap();\n    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());\n    write!(fh, r#\"\"{now}\"\"#).ok();\n\n    let git_config = File::from_git_dir(\"../.git/\".into()).expect(\"Git Config not found!\");\n    // Get Git's 'Origin' URL\n    let mut remote_url = git_config\n        .raw_value(\"remote.origin.url\")\n        .expect(\"Couldn't extract origin url!\")\n        .to_string();\n\n    // Check whether origin is accessed via ssh\n    if remote_url.contains('@') {\n        // If yes, strip the `git@` prefix and split the domain and path\n        let mut split = remote_url\n            .strip_prefix(\"git@\")\n            .unwrap_or(&remote_url)\n            .split(':');\n        let domain = split\n            .next()\n            .expect(\"Couldn't extract domain from ssh-style origin\");\n        let path = split\n            .next()\n            .expect(\"Couldn't expect path from ssh-style origin\");\n\n        // And construct the http-style url\n        remote_url = format!(\"https://{domain}/{path}\");\n    }\n    let trimmed_url = remote_url.trim_end_matches(\".git\");\n    remote_url.clone_from(&String::from(trimmed_url));\n\n    let outfile = format!(\"{outdir}/remote-url.txt\");\n    let mut file = fs::File::create(outfile).unwrap();\n    write!(file, r#\"\"{remote_url}\"\"#).ok();\n}\n"
  },
  {
    "path": "psst-core/src/actor.rs",
    "content": "use std::{\n    fmt::Display,\n    thread::{self, JoinHandle},\n    time::Duration,\n};\n\nuse crossbeam_channel::{\n    bounded, unbounded, Receiver, RecvTimeoutError, SendError, Sender, TrySendError,\n};\n\npub enum Act<T: Actor> {\n    Continue,\n    WaitOr {\n        timeout: Duration,\n        timeout_msg: T::Message,\n    },\n    Shutdown,\n}\n\npub trait Actor: Sized {\n    type Message: Send + 'static;\n    type Error: Display;\n\n    fn handle(&mut self, msg: Self::Message) -> Result<Act<Self>, Self::Error>;\n\n    fn process(mut self, recv: Receiver<Self::Message>) {\n        let mut act = Act::Continue;\n        loop {\n            let msg = match act {\n                Act::Continue => match recv.recv() {\n                    Ok(msg) => msg,\n                    Err(_) => {\n                        break;\n                    }\n                },\n                Act::WaitOr {\n                    timeout,\n                    timeout_msg,\n                } => match recv.recv_timeout(timeout) {\n                    Ok(msg) => msg,\n                    Err(RecvTimeoutError::Timeout) => timeout_msg,\n                    Err(RecvTimeoutError::Disconnected) => {\n                        break;\n                    }\n                },\n                Act::Shutdown => {\n                    break;\n                }\n            };\n            act = match self.handle(msg) {\n                Ok(act) => act,\n                Err(err) => {\n                    log::error!(\"error: {err}\");\n                    break;\n                }\n            };\n        }\n    }\n\n    fn spawn<F>(cap: Capacity, name: &str, factory: F) -> ActorHandle<Self::Message>\n    where\n        F: FnOnce(Sender<Self::Message>) -> Self + Send + 'static,\n    {\n        let (send, recv) = cap.to_channel();\n        ActorHandle {\n            sender: send.clone(),\n            thread: thread::Builder::new()\n                .name(name.to_string())\n                .spawn(move || {\n                    factory(send).process(recv);\n                })\n                .unwrap(),\n        }\n    }\n\n    fn spawn_with_default_cap<F>(name: &str, factory: F) -> ActorHandle<Self::Message>\n    where\n        F: FnOnce(Sender<Self::Message>) -> Self + Send + 'static,\n    {\n        Self::spawn(Capacity::Bounded(128), name, factory)\n    }\n}\n\npub struct ActorHandle<M> {\n    thread: JoinHandle<()>,\n    sender: Sender<M>,\n}\n\nimpl<M> ActorHandle<M> {\n    pub fn sender(&self) -> Sender<M> {\n        self.sender.clone()\n    }\n\n    pub fn join(self) {\n        let _ = self.thread.join();\n    }\n\n    pub fn send(&self, msg: M) -> Result<(), SendError<M>> {\n        self.sender.send(msg)\n    }\n\n    pub fn try_send(&self, msg: M) -> Result<(), TrySendError<M>> {\n        self.sender.try_send(msg)\n    }\n}\n\npub enum Capacity {\n    Sync,\n    Bounded(usize),\n    Unbounded,\n}\n\nimpl Capacity {\n    pub fn to_channel<T>(&self) -> (Sender<T>, Receiver<T>) {\n        match self {\n            Capacity::Sync => bounded(0),\n            Capacity::Bounded(cap) => bounded(*cap),\n            Capacity::Unbounded => unbounded(),\n        }\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/decode.rs",
    "content": "use std::{io, time::Duration};\n\nuse symphonia::{\n    core::{\n        audio::{SampleBuffer, SignalSpec},\n        codecs::{CodecParameters, Decoder, DecoderOptions},\n        conv::ConvertibleSample,\n        errors::Error as SymphoniaError,\n        formats::{FormatOptions, FormatReader, SeekMode, SeekTo},\n        io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},\n        units::TimeStamp,\n    },\n    default::{\n        codecs::{MpaDecoder, VorbisDecoder},\n        formats::{MpaReader, OggReader},\n    },\n};\n\nuse crate::{error::Error, util::FileWithConstSize};\n\npub enum AudioCodecFormat {\n    Mp3,\n    OggVorbis,\n}\n\nimpl AudioCodecFormat {\n    fn format_reader(\n        &self,\n        mss: MediaSourceStream,\n    ) -> Result<Box<dyn FormatReader>, SymphoniaError> {\n        match self {\n            Self::Mp3 => Ok(Box::new(MpaReader::try_new(\n                mss,\n                &FormatOptions::default(),\n            )?)),\n            Self::OggVorbis => Ok(Box::new(OggReader::try_new(\n                mss,\n                &FormatOptions::default(),\n            )?)),\n        }\n    }\n\n    fn decoder(&self, codec_params: &CodecParameters) -> Result<Box<dyn Decoder>, SymphoniaError> {\n        match self {\n            Self::Mp3 => Ok(Box::new(MpaDecoder::try_new(\n                codec_params,\n                &DecoderOptions::default(),\n            )?)),\n            Self::OggVorbis => Ok(Box::new(VorbisDecoder::try_new(\n                codec_params,\n                &DecoderOptions::default(),\n            )?)),\n        }\n    }\n}\n\npub struct AudioDecoder {\n    track_id: u32, // Internal track index.\n    decoder: Box<dyn Decoder>,\n    format: Box<dyn FormatReader>,\n}\n\nimpl AudioDecoder {\n    pub fn new<T>(input: T, codec: AudioCodecFormat) -> Result<Self, Error>\n    where\n        T: io::Read + io::Seek + Send + Sync + 'static,\n    {\n        let mss = MediaSourceStream::new(\n            Box::new(FileWithConstSize::new(input)),\n            MediaSourceStreamOptions::default(),\n        );\n        let format = codec.format_reader(mss)?;\n        let track = format.default_track().unwrap();\n        let decoder = codec.decoder(&track.codec_params)?;\n\n        Ok(Self {\n            track_id: track.id,\n            decoder,\n            format,\n        })\n    }\n\n    pub fn codec_params(&self) -> &CodecParameters {\n        self.decoder.codec_params()\n    }\n\n    pub fn signal_spec(&self) -> SignalSpec {\n        SignalSpec {\n            rate: self.codec_params().sample_rate.unwrap(),\n            channels: self.codec_params().channels.unwrap(),\n        }\n    }\n\n    pub fn seek(&mut self, time: Duration) -> Result<TimeStamp, Error> {\n        let seeked_to = self.format.seek(\n            SeekMode::Accurate,\n            SeekTo::Time {\n                time: time.as_secs_f64().into(),\n                track_id: Some(self.track_id),\n            },\n        )?;\n        Ok(seeked_to.actual_ts)\n    }\n\n    /// Read a next packet of audio from this decoder.  Returns `None` in case\n    /// of EOF or internal error.\n    pub fn read_packet<S>(&mut self, samples: &mut SampleBuffer<S>) -> Option<TimeStamp>\n    where\n        S: ConvertibleSample,\n    {\n        loop {\n            // Demux an encoded packet from the media format.\n            let packet = match self.format.next_packet() {\n                Ok(packet) => packet,\n                Err(SymphoniaError::IoError(io)) if io.kind() == io::ErrorKind::UnexpectedEof => {\n                    return None; // End of this stream.\n                }\n                Err(err) => {\n                    log::error!(\"format error: {err}\");\n                    return None; // We cannot recover from format errors, quit.\n                }\n            };\n            while !self.format.metadata().is_latest() {\n                // Consume any new metadata that has been read since the last\n                // packet.\n            }\n            // If the packet does not belong to the selected track, skip over it.\n            if packet.track_id() != self.track_id {\n                continue;\n            }\n            // Decode the packet into an audio buffer.\n            match self.decoder.decode(&packet) {\n                Ok(decoded) => {\n                    // Interleave the samples into the buffer.\n                    samples.copy_interleaved_ref(decoded);\n                    return Some(packet.ts());\n                }\n                Err(SymphoniaError::IoError(err)) => {\n                    // The packet failed to decode due to an IO error, skip the packet.\n                    log::error!(\"io decode error: {err}\");\n                    continue;\n                }\n                Err(SymphoniaError::DecodeError(err)) => {\n                    // The packet failed to decode due to invalid data, skip the packet.\n                    log::error!(\"decode error: {err}\");\n                    continue;\n                }\n                Err(err) => {\n                    log::error!(\"fatal decode error: {err}\");\n                    return None;\n                }\n            };\n        }\n    }\n}\n\nimpl<T> MediaSource for FileWithConstSize<T>\nwhere\n    T: io::Read + io::Seek + Send + Sync,\n{\n    fn is_seekable(&self) -> bool {\n        true\n    }\n\n    fn byte_len(&self) -> Option<u64> {\n        Some(self.len())\n    }\n}\n\nimpl From<SymphoniaError> for Error {\n    fn from(err: SymphoniaError) -> Error {\n        Error::AudioDecodingError(Box::new(err))\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/decrypt.rs",
    "content": "use std::{convert::TryInto, io};\n\nuse aes::{\n    cipher::{generic_array::GenericArray, KeyIvInit, StreamCipher, StreamCipherSeek},\n    Aes128,\n};\nuse ctr::Ctr128BE;\n\nconst AUDIO_AESIV: [u8; 16] = [\n    0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93,\n];\n\n#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]\npub struct AudioKey(pub [u8; 16]);\n\nimpl AudioKey {\n    pub fn from_raw(data: &[u8]) -> Option<Self> {\n        Some(AudioKey(data.try_into().ok()?))\n    }\n}\n\npub struct AudioDecrypt<T> {\n    cipher: Ctr128BE<Aes128>,\n    reader: T,\n}\n\nimpl<T: io::Read> AudioDecrypt<T> {\n    pub fn new(key: AudioKey, reader: T) -> AudioDecrypt<T> {\n        let cipher = Ctr128BE::<Aes128>::new(\n            GenericArray::from_slice(&key.0),\n            GenericArray::from_slice(&AUDIO_AESIV),\n        );\n        AudioDecrypt { cipher, reader }\n    }\n}\n\nimpl<T: io::Read> io::Read for AudioDecrypt<T> {\n    fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {\n        let len = self.reader.read(output)?;\n\n        self.cipher.apply_keystream(&mut output[..len]);\n\n        Ok(len)\n    }\n}\n\nimpl<T: io::Read + io::Seek> io::Seek for AudioDecrypt<T> {\n    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {\n        let newpos = self.reader.seek(pos)?;\n\n        self.cipher.seek(newpos);\n\n        Ok(newpos)\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/mod.rs",
    "content": "pub mod decode;\npub mod decrypt;\npub mod normalize;\npub mod output;\npub mod probe;\npub mod resample;\npub mod source;\n"
  },
  {
    "path": "psst-core/src/audio/normalize.rs",
    "content": "use std::{\n    io,\n    io::{Read, Seek, SeekFrom},\n};\n\nuse byteorder::{ReadBytesExt, LE};\n\n#[derive(Debug, Clone, Copy, Eq, PartialEq)]\npub enum NormalizationLevel {\n    None,\n    Track,\n    Album,\n}\n\n#[derive(Clone, Copy)]\npub struct NormalizationData {\n    track_gain_db: f32,\n    track_peak: f32,\n    album_gain_db: f32,\n    album_peak: f32,\n}\n\nimpl NormalizationData {\n    pub fn parse(mut file: impl Read + Seek) -> io::Result<Self> {\n        const NORMALIZATION_OFFSET: u64 = 144;\n\n        file.seek(SeekFrom::Start(NORMALIZATION_OFFSET))?;\n\n        let track_gain_db = file.read_f32::<LE>()?;\n        let track_peak = file.read_f32::<LE>()?;\n        let album_gain_db = file.read_f32::<LE>()?;\n        let album_peak = file.read_f32::<LE>()?;\n\n        Ok(Self {\n            track_gain_db,\n            track_peak,\n            album_gain_db,\n            album_peak,\n        })\n    }\n\n    pub fn factor_for_level(&self, level: NormalizationLevel, pregain: f32) -> f32 {\n        match level {\n            NormalizationLevel::None => 1.0,\n            NormalizationLevel::Track => Self::factor(pregain, self.track_gain_db, self.track_peak),\n            NormalizationLevel::Album => Self::factor(pregain, self.album_gain_db, self.album_peak),\n        }\n    }\n\n    fn factor(pregain: f32, gain: f32, peak: f32) -> f32 {\n        let mut nf = f32::powf(10.0, (pregain + gain) / 20.0);\n        if nf * peak > 1.0 {\n            nf = 1.0 / peak;\n        }\n        nf\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/output/cpal.rs",
    "content": "use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};\nuse crossbeam_channel::{bounded, Receiver, Sender};\nuse num_traits::Pow;\n\nuse crate::{\n    actor::{Act, Actor, ActorHandle},\n    audio::{\n        output::{AudioOutput, AudioSink},\n        source::{AudioSource, Empty},\n    },\n    error::Error,\n};\n\npub struct CpalOutput {\n    _handle: ActorHandle<StreamMsg>,\n    sink: CpalSink,\n}\n\nimpl CpalOutput {\n    pub fn open() -> Result<Self, Error> {\n        // Open the default output device.\n        let device = cpal::default_host()\n            .default_output_device()\n            .ok_or(cpal::DefaultStreamConfigError::DeviceNotAvailable)?;\n\n        if let Ok(name) = device.name() {\n            log::info!(\"using audio device: {name:?}\");\n        }\n\n        // Get the default device config, so we know what sample format and sample rate\n        // the device supports.\n        let supported = Self::preferred_output_config(&device)?;\n\n        let (callback_send, callback_recv) = bounded(16);\n\n        let handle = Stream::spawn_with_default_cap(\"audio_output\", {\n            let config = supported.config();\n            // TODO: Support additional sample formats.\n            move |this| Stream::open(device, config, callback_recv, this).unwrap()\n        });\n        let sink = CpalSink {\n            channel_count: supported.channels(),\n            sample_rate: supported.sample_rate(),\n            stream_send: handle.sender(),\n            callback_send,\n        };\n\n        Ok(Self {\n            _handle: handle,\n            sink,\n        })\n    }\n\n    fn preferred_output_config(\n        device: &cpal::Device,\n    ) -> Result<cpal::SupportedStreamConfig, Error> {\n        const PREFERRED_SAMPLE_FORMAT: cpal::SampleFormat = cpal::SampleFormat::F32;\n        const PREFERRED_SAMPLE_RATE: cpal::SampleRate = cpal::SampleRate(44_100);\n        const PREFERRED_CHANNELS: cpal::ChannelCount = 2;\n\n        for s in device.supported_output_configs()? {\n            let rates = s.min_sample_rate()..=s.max_sample_rate();\n            if s.channels() == PREFERRED_CHANNELS\n                && s.sample_format() == PREFERRED_SAMPLE_FORMAT\n                && rates.contains(&PREFERRED_SAMPLE_RATE)\n            {\n                return Ok(s.with_sample_rate(PREFERRED_SAMPLE_RATE));\n            }\n        }\n\n        Ok(device.default_output_config()?)\n    }\n}\n\nimpl AudioOutput for CpalOutput {\n    type Sink = CpalSink;\n\n    fn sink(&self) -> Self::Sink {\n        self.sink.clone()\n    }\n}\n\n#[derive(Clone)]\npub struct CpalSink {\n    channel_count: cpal::ChannelCount,\n    sample_rate: cpal::SampleRate,\n    callback_send: Sender<CallbackMsg>,\n    stream_send: Sender<StreamMsg>,\n}\n\nimpl CpalSink {\n    fn send_to_callback(&self, msg: CallbackMsg) {\n        if self.callback_send.send(msg).is_err() {\n            log::error!(\"output stream actor is dead\");\n        }\n    }\n\n    fn send_to_stream(&self, msg: StreamMsg) {\n        if self.stream_send.send(msg).is_err() {\n            log::error!(\"output stream actor is dead\");\n        }\n    }\n}\n\nimpl AudioSink for CpalSink {\n    fn channel_count(&self) -> usize {\n        self.channel_count as usize\n    }\n\n    fn sample_rate(&self) -> u32 {\n        self.sample_rate.0\n    }\n\n    fn set_volume(&self, volume: f32) {\n        self.send_to_callback(CallbackMsg::SetVolume(volume));\n    }\n\n    fn play(&self, source: impl AudioSource) {\n        self.send_to_callback(CallbackMsg::PlaySource(Box::new(source)));\n    }\n\n    fn pause(&self) {\n        self.send_to_stream(StreamMsg::Pause);\n        self.send_to_callback(CallbackMsg::Pause);\n    }\n\n    fn resume(&self) {\n        self.send_to_stream(StreamMsg::Resume);\n        self.send_to_callback(CallbackMsg::Resume);\n    }\n\n    fn stop(&self) {\n        self.play(Empty);\n        self.pause();\n    }\n\n    fn close(&self) {\n        self.send_to_stream(StreamMsg::Close);\n    }\n}\n\nstruct Stream {\n    stream: cpal::Stream,\n    _device: cpal::Device,\n}\n\nimpl Stream {\n    fn open(\n        device: cpal::Device,\n        config: cpal::StreamConfig,\n        callback_recv: Receiver<CallbackMsg>,\n        stream_send: Sender<StreamMsg>,\n    ) -> Result<Self, Error> {\n        let mut callback = StreamCallback {\n            callback_recv,\n            stream_send,\n            source: Box::new(Empty),\n            volume: 1.0, // We start with the full volume.\n            state: CallbackState::Paused,\n        };\n\n        log::info!(\"opening output stream: {config:?}\");\n        let stream = device.build_output_stream(\n            &config,\n            move |output, _| {\n                callback.write_samples(output);\n            },\n            |err| {\n                log::error!(\"audio output error: {err}\");\n            },\n            None,\n        )?;\n\n        Ok(Self {\n            _device: device,\n            stream,\n        })\n    }\n}\n\nimpl Actor for Stream {\n    type Message = StreamMsg;\n    type Error = Error;\n\n    fn handle(&mut self, msg: Self::Message) -> Result<Act<Self>, Self::Error> {\n        match msg {\n            StreamMsg::Pause => {\n                log::debug!(\"pausing audio output stream\");\n                if let Err(err) = self.stream.pause() {\n                    log::error!(\"failed to stop stream: {err}\");\n                }\n                Ok(Act::Continue)\n            }\n            StreamMsg::Resume => {\n                log::debug!(\"resuming audio output stream\");\n                if let Err(err) = self.stream.play() {\n                    log::error!(\"failed to start stream: {err}\");\n                }\n                Ok(Act::Continue)\n            }\n            StreamMsg::Close => {\n                log::debug!(\"closing audio output stream\");\n                let _ = self.stream.pause();\n                Ok(Act::Shutdown)\n            }\n        }\n    }\n}\n\nenum StreamMsg {\n    Pause,\n    Resume,\n    Close,\n}\n\nenum CallbackMsg {\n    PlaySource(Box<dyn AudioSource>),\n    SetVolume(f32),\n    Pause,\n    Resume,\n}\n\nenum CallbackState {\n    Playing,\n    Paused,\n}\n\nstruct StreamCallback {\n    #[allow(unused)]\n    stream_send: Sender<StreamMsg>,\n    callback_recv: Receiver<CallbackMsg>,\n    source: Box<dyn AudioSource>,\n    state: CallbackState,\n    volume: f32,\n}\n\nimpl StreamCallback {\n    fn write_samples(&mut self, output: &mut [f32]) {\n        // Process any pending data messages.\n        while let Ok(msg) = self.callback_recv.try_recv() {\n            match msg {\n                CallbackMsg::PlaySource(src) => {\n                    self.source = src;\n                }\n                CallbackMsg::SetVolume(volume) => {\n                    self.volume = volume;\n                }\n                CallbackMsg::Pause => {\n                    self.state = CallbackState::Paused;\n                }\n                CallbackMsg::Resume => {\n                    self.state = CallbackState::Playing;\n                }\n            }\n        }\n\n        let written = if matches!(self.state, CallbackState::Playing) {\n            // Write out as many samples as possible from the audio source to the\n            // output buffer.\n            let written = self.source.write(output);\n\n            // Apply scaled global volume level.\n            let scaled_volume = self.volume.pow(4);\n            output[..written]\n                .iter_mut()\n                .for_each(|s| *s *= scaled_volume);\n\n            written\n        } else {\n            0\n        };\n\n        // Mute any remaining samples.\n        output[written..].iter_mut().for_each(|s| *s = 0.0);\n    }\n}\n\nimpl From<cpal::DefaultStreamConfigError> for Error {\n    fn from(err: cpal::DefaultStreamConfigError) -> Error {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n\nimpl From<cpal::SupportedStreamConfigsError> for Error {\n    fn from(err: cpal::SupportedStreamConfigsError) -> Error {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n\nimpl From<cpal::BuildStreamError> for Error {\n    fn from(err: cpal::BuildStreamError) -> Error {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n\nimpl From<cpal::PlayStreamError> for Error {\n    fn from(err: cpal::PlayStreamError) -> Error {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n\nimpl From<cpal::PauseStreamError> for Error {\n    fn from(err: cpal::PauseStreamError) -> Error {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/output/cubeb.rs",
    "content": "use std::{env, ffi::CString, ops::Deref};\n\nuse crossbeam_channel::{bounded, Receiver, Sender};\n\nuse crate::{\n    actor::{Act, Actor, ActorHandle},\n    audio::{\n        output::{AudioOutput, AudioSink},\n        source::{AudioSource, Empty},\n    },\n    error::Error,\n};\n\npub struct CubebOutput {\n    #[allow(unused)]\n    handle: ActorHandle<StreamMsg>,\n    sink: CubebSink,\n}\n\nimpl CubebOutput {\n    pub fn open() -> Result<Self, Error> {\n        let (callback_send, callback_recv) = bounded(16);\n\n        let handle = Stream::spawn_with_default_cap(\"audio_output\", {\n            move |_| Stream::open(callback_recv).unwrap()\n        });\n        let sink = CubebSink {\n            callback_send,\n            stream_send: handle.sender(),\n        };\n\n        Ok(Self { handle, sink })\n    }\n}\n\nimpl AudioOutput for CubebOutput {\n    type Sink = CubebSink;\n\n    fn sink(&self) -> Self::Sink {\n        self.sink.clone()\n    }\n}\n\ntype Frame = cubeb::StereoFrame<f32>;\n\nconst STREAM_CHANNELS: usize = 2;\nconst SAMPLE_RATE: u32 = 44_100;\nconst STREAM_LATENCY: u32 = 0x1000;\n\nstruct Stream {\n    #[allow(unused)]\n    ctx: cubeb::Context,\n    stream: cubeb::Stream<Frame>,\n}\n\nimpl Stream {\n    fn open(callback_recv: Receiver<CallbackMsg>) -> Result<Self, Error> {\n        // Call CoInitialize() before any other calls to the API.\n        #[cfg(target_os = \"windows\")]\n        unsafe {\n            let _ = windows::Win32::System::Com::CoInitialize(0 as *mut _);\n        };\n\n        let backend_name = env::var(\"CUBEB_BACKEND\")\n            .ok()\n            .and_then(|s| CString::new(s).ok());\n        let ctx_name = CString::new(\"Psst\").ok();\n        let ctx = cubeb::Context::init(ctx_name.as_deref(), backend_name.as_deref())?;\n\n        let mut callback = StreamCallback {\n            callback_recv,\n            source: Box::new(Empty),\n            state: CallbackState::Paused,\n            buffer: vec![0.0; 1024 * 1024],\n        };\n\n        let params = cubeb::StreamParamsBuilder::new()\n            .format(cubeb::SampleFormat::Float32NE)\n            .rate(SAMPLE_RATE)\n            .channels(STREAM_CHANNELS as u32)\n            .layout(cubeb::ChannelLayout::STEREO)\n            .take();\n\n        let mut builder = cubeb::StreamBuilder::new();\n        builder\n            .name(\"Psst\")\n            .default_output(&params)\n            .latency(STREAM_LATENCY)\n            .data_callback(move |_, output| {\n                callback.write_samples(output);\n                output.len() as isize\n            })\n            .state_callback(|state| {\n                log::debug!(\"stream state: {:?}\", state);\n            });\n        let stream = builder.init(&ctx)?;\n\n        Ok(Self { ctx, stream })\n    }\n}\n\nenum StreamMsg {\n    Pause,\n    Resume,\n    Close,\n    SetVolume(f32),\n}\n\nimpl Actor for Stream {\n    type Message = StreamMsg;\n    type Error = Error;\n\n    fn handle(&mut self, msg: Self::Message) -> Result<Act<Self>, Self::Error> {\n        match msg {\n            StreamMsg::Pause => {\n                log::debug!(\"pausing audio output stream\");\n                if let Err(err) = self.stream.stop() {\n                    log::error!(\"failed to stop stream: {}\", err);\n                }\n                Ok(Act::Continue)\n            }\n            StreamMsg::Resume => {\n                log::debug!(\"resuming audio output stream\");\n                if let Err(err) = self.stream.start() {\n                    log::error!(\"failed to start stream: {}\", err);\n                }\n                Ok(Act::Continue)\n            }\n            StreamMsg::Close => {\n                log::debug!(\"closing audio output stream\");\n                let _ = self.stream.stop();\n                Ok(Act::Shutdown)\n            }\n            StreamMsg::SetVolume(volume) => {\n                log::debug!(\"setting volume\");\n                if let Err(err) = self.stream.set_volume(volume) {\n                    log::error!(\"failed to set volume: {}\", err);\n                }\n                Ok(Act::Continue)\n            }\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct CubebSink {\n    callback_send: Sender<CallbackMsg>,\n    stream_send: Sender<StreamMsg>,\n}\n\nimpl AudioSink for CubebSink {\n    fn channel_count(&self) -> usize {\n        STREAM_CHANNELS\n    }\n\n    fn sample_rate(&self) -> u32 {\n        SAMPLE_RATE\n    }\n\n    fn set_volume(&self, volume: f32) {\n        self.stream_send.send(StreamMsg::SetVolume(volume)).unwrap();\n    }\n\n    fn play(&self, source: impl AudioSource) {\n        self.callback_send\n            .send(CallbackMsg::PlaySource(Box::new(source)))\n            .unwrap()\n    }\n\n    fn pause(&self) {\n        self.callback_send.send(CallbackMsg::Pause).unwrap();\n        self.stream_send.send(StreamMsg::Pause).unwrap();\n    }\n\n    fn resume(&self) {\n        self.callback_send.send(CallbackMsg::Resume).unwrap();\n        self.stream_send.send(StreamMsg::Resume).unwrap();\n    }\n\n    fn stop(&self) {\n        self.pause();\n    }\n\n    fn close(&self) {\n        self.stop();\n    }\n}\n\nenum CallbackMsg {\n    PlaySource(Box<dyn AudioSource>),\n    Pause,\n    Resume,\n}\n\nenum CallbackState {\n    Playing,\n    Paused,\n}\n\nstruct StreamCallback {\n    callback_recv: Receiver<CallbackMsg>,\n    source: Box<dyn AudioSource>,\n    state: CallbackState,\n    buffer: Vec<f32>,\n}\n\nimpl StreamCallback {\n    fn write_samples(&mut self, output: &mut [Frame]) {\n        // Process any pending data messages.\n        while let Ok(msg) = self.callback_recv.try_recv() {\n            match msg {\n                CallbackMsg::PlaySource(src) => {\n                    self.source = src;\n                }\n                CallbackMsg::Pause => {\n                    self.state = CallbackState::Paused;\n                }\n                CallbackMsg::Resume => {\n                    self.state = CallbackState::Playing;\n                }\n            }\n        }\n\n        let written = if matches!(self.state, CallbackState::Playing) {\n            // Write out as many samples as possible from the audio source to the\n            // output buffer.\n            let n_output_frames = output.len();\n            let n_output_samples = n_output_frames * STREAM_CHANNELS;\n            let n_samples = self.source.write(&mut self.buffer[..n_output_samples]);\n            let mut n_frames = 0;\n            for (i, o) in self.buffer[..n_samples]\n                .chunks(STREAM_CHANNELS)\n                .zip(output.iter_mut())\n            {\n                o.l = i[0];\n                o.r = i[1];\n                n_frames += 1;\n            }\n            n_frames\n        } else {\n            0\n        };\n\n        // Mute any remaining samples.\n        output[written..].iter_mut().for_each(|s| {\n            s.l = 0.0;\n            s.r = 0.0;\n        });\n    }\n}\n\nunsafe impl Sync for StreamCallback {}\n\nimpl From<cubeb::Error> for Error {\n    fn from(err: cubeb::Error) -> Self {\n        Error::AudioOutputError(Box::new(err))\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/output/mod.rs",
    "content": "use crate::audio::source::AudioSource;\n\n#[cfg(feature = \"cpal\")]\npub mod cpal;\n#[cfg(feature = \"cubeb\")]\npub mod cubeb;\n\n#[cfg(feature = \"cubeb\")]\npub type DefaultAudioOutput = cubeb::CubebOutput;\n#[cfg(feature = \"cpal\")]\npub type DefaultAudioOutput = cpal::CpalOutput;\n\npub type DefaultAudioSink = <DefaultAudioOutput as AudioOutput>::Sink;\n\npub trait AudioOutput {\n    type Sink: AudioSink;\n\n    fn sink(&self) -> Self::Sink;\n}\n\npub trait AudioSink {\n    fn channel_count(&self) -> usize;\n    fn sample_rate(&self) -> u32;\n    fn set_volume(&self, volume: f32);\n    fn play(&self, source: impl AudioSource);\n    fn pause(&self);\n    fn resume(&self);\n    fn stop(&self);\n    fn close(&self);\n}\n"
  },
  {
    "path": "psst-core/src/audio/probe.rs",
    "content": "use std::fs::File;\nuse std::path::PathBuf;\nuse std::time::Duration;\n\nuse symphonia::core::codecs::CodecType;\nuse symphonia::core::formats::FormatOptions;\nuse symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};\nuse symphonia::core::meta::MetadataOptions;\nuse symphonia::core::probe::{Hint, Probe};\nuse symphonia::default::formats::{MpaReader, OggReader};\n\nuse crate::error::Error;\n\npub struct TrackProbe {\n    pub codec: CodecType,\n    pub duration: Option<Duration>,\n}\n\nmacro_rules! probe_err {\n    ($message:tt) => {\n        // This is necessary to work around the fact that the two impls for From<&str> are:\n        //   Box<dyn std::error::Error>\n        //   Box<dyn std::error::Error + Send + Sync>\n        // And the trait bound on our error is:\n        //   Box<dyn std::error::Error + Send>\n        // Normally we could just do `$message.into()`, but no impl exists for exactly\n        // `Error + Send`, so we have to be explicit about which we want to use.\n        Error::AudioProbeError(Box::<dyn std::error::Error + Send + Sync>::from($message))\n    };\n}\n\nimpl TrackProbe {\n    pub fn new(path: &PathBuf) -> Result<Self, Error> {\n        // Register all supported file formats for detection.\n        let mut probe = Probe::default();\n        probe.register_all::<MpaReader>();\n        probe.register_all::<OggReader>();\n\n        let mut hint = Hint::new();\n        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {\n            hint.with_extension(ext);\n        }\n\n        let file = File::open(path)?;\n        let mss_opts = MediaSourceStreamOptions::default();\n        let mss = MediaSourceStream::new(Box::new(file), mss_opts);\n\n        let fmt_opts = FormatOptions::default();\n        let meta_opts = MetadataOptions::default();\n        let probe_result = probe\n            .format(&hint, mss, &fmt_opts, &meta_opts)\n            .map_err(|_| probe_err!(\"failed to probe file\"))?;\n        let track = probe_result\n            .format\n            .default_track()\n            .ok_or_else(|| probe_err!(\"file contained no tracks\"))?;\n        let params = &track.codec_params;\n\n        let duration =\n            if let (Some(time_base), Some(n_frames)) = (params.time_base, params.n_frames) {\n                let time = time_base.calc_time(n_frames);\n                let secs = time.seconds;\n                let ms = (time.frac * 1_000.0).round() as u64;\n                Some(Duration::from_millis(secs * 1_000 + ms))\n            } else {\n                None\n            };\n\n        Ok(Self {\n            codec: params.codec,\n            duration,\n        })\n    }\n}\n"
  },
  {
    "path": "psst-core/src/audio/resample.rs",
    "content": "use crate::error::Error;\n\n#[derive(Copy, Clone)]\npub enum ResamplingQuality {\n    SincBestQuality = libsamplerate::SRC_SINC_BEST_QUALITY as isize,\n    SincMediumQuality = libsamplerate::SRC_SINC_MEDIUM_QUALITY as isize,\n    SincFastest = libsamplerate::SRC_SINC_FASTEST as isize,\n    ZeroOrderHold = libsamplerate::SRC_ZERO_ORDER_HOLD as isize,\n    Linear = libsamplerate::SRC_LINEAR as isize,\n}\n\n#[derive(Copy, Clone)]\npub struct ResamplingSpec {\n    pub input_rate: u32,\n    pub output_rate: u32,\n    pub channels: usize,\n}\n\nimpl ResamplingSpec {\n    pub fn output_size(&self, input_size: usize) -> usize {\n        (self.output_rate as f64 / self.input_rate as f64 * input_size as f64) as usize\n    }\n\n    pub fn input_size(&self, output_size: usize) -> usize {\n        (self.input_rate as f64 / self.output_rate as f64 * output_size as f64) as usize\n    }\n\n    pub fn ratio(&self) -> f64 {\n        self.output_rate as f64 / self.input_rate as f64\n    }\n}\n\npub struct AudioResampler {\n    pub spec: ResamplingSpec,\n    state: *mut libsamplerate::SRC_STATE,\n}\n\nimpl AudioResampler {\n    pub fn new(quality: ResamplingQuality, spec: ResamplingSpec) -> Result<Self, Error> {\n        let mut error_int = 0i32;\n        let state = unsafe {\n            libsamplerate::src_new(\n                quality as i32,\n                spec.channels as i32,\n                &mut error_int as *mut i32,\n            )\n        };\n        if error_int != 0 {\n            Err(Error::ResamplingError(error_int))\n        } else {\n            Ok(Self { state, spec })\n        }\n    }\n\n    pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> Result<(usize, usize), Error> {\n        if self.spec.input_rate == self.spec.output_rate {\n            // Bypass conversion completely in case the sample rates are equal.\n            let output = &mut output[..input.len()];\n            output.copy_from_slice(input);\n            return Ok((input.len(), output.len()));\n        }\n        let mut src = libsamplerate::SRC_DATA {\n            data_in: input.as_ptr(),\n            data_out: output.as_mut_ptr(),\n            input_frames: (input.len() / self.spec.channels) as _,\n            output_frames: (output.len() / self.spec.channels) as _,\n            src_ratio: self.spec.ratio(),\n            end_of_input: 0, // TODO: Use this.\n            input_frames_used: 0,\n            output_frames_gen: 0,\n        };\n        let error_int = unsafe { libsamplerate::src_process(self.state, &mut src as *mut _) };\n        if error_int != 0 {\n            Err(Error::ResamplingError(error_int))\n        } else {\n            let processed_len = src.input_frames_used as usize * self.spec.channels;\n            let output_len = src.output_frames_gen as usize * self.spec.channels;\n            Ok((processed_len, output_len))\n        }\n    }\n}\n\nimpl Drop for AudioResampler {\n    fn drop(&mut self) {\n        unsafe { libsamplerate::src_delete(self.state) };\n    }\n}\n\nunsafe impl Send for AudioResampler {}\n"
  },
  {
    "path": "psst-core/src/audio/source.rs",
    "content": "use crate::audio::resample::ResamplingSpec;\n\nuse super::resample::{AudioResampler, ResamplingQuality};\n\n/// Types that can produce audio samples in `f32` format. `Send`able across\n/// threads.\npub trait AudioSource: Send + 'static {\n    /// Write at most of `output.len()` samples into the `output`. Returns the\n    /// number of written samples. Should take care to always output a full\n    /// frame, and should _never_ block.\n    fn write(&mut self, output: &mut [f32]) -> usize;\n    fn channel_count(&self) -> usize;\n    fn sample_rate(&self) -> u32;\n}\n\n/// Empty audio source. Does not produce any samples.\npub struct Empty;\n\nimpl AudioSource for Empty {\n    fn write(&mut self, _output: &mut [f32]) -> usize {\n        0\n    }\n\n    fn channel_count(&self) -> usize {\n        0\n    }\n\n    fn sample_rate(&self) -> u32 {\n        0\n    }\n}\n\npub struct StereoMappedSource<S> {\n    source: S,\n    input_channels: usize,\n    output_channels: usize,\n    buffer: Vec<f32>,\n}\n\nimpl<S> StereoMappedSource<S>\nwhere\n    S: AudioSource,\n{\n    pub fn new(source: S, output_channels: usize) -> Self {\n        const BUFFER_SIZE: usize = 16 * 1024;\n\n        let input_channels = source.channel_count();\n        Self {\n            source,\n            input_channels,\n            output_channels,\n            buffer: vec![0.0; BUFFER_SIZE],\n        }\n    }\n}\n\nimpl<S> AudioSource for StereoMappedSource<S>\nwhere\n    S: AudioSource,\n{\n    fn write(&mut self, output: &mut [f32]) -> usize {\n        let input_max = (output.len() / self.output_channels) * self.input_channels;\n        let buffer_max = input_max.min(self.buffer.len());\n        let written = self.source.write(&mut self.buffer[..buffer_max]);\n        let input = &self.buffer[..written];\n        let input_frames = input.chunks_exact(self.input_channels);\n        let output_frames = output.chunks_exact_mut(self.output_channels);\n        for (i, o) in input_frames.zip(output_frames) {\n            o[0] = i[0];\n            o[1] = i[1];\n            // Assume the rest is is implicitly silence.\n        }\n        output.len()\n    }\n\n    fn channel_count(&self) -> usize {\n        self.output_channels\n    }\n\n    fn sample_rate(&self) -> u32 {\n        self.source.sample_rate()\n    }\n}\n\npub struct ResampledSource<S> {\n    source: S,\n    resampler: AudioResampler,\n    inp: Buf,\n    out: Buf,\n}\n\nimpl<S> ResampledSource<S> {\n    pub fn new(source: S, output_sample_rate: u32, quality: ResamplingQuality) -> Self\n    where\n        S: AudioSource,\n    {\n        const BUFFER_SIZE: usize = 1024;\n\n        let spec = ResamplingSpec {\n            channels: source.channel_count(),\n            input_rate: source.sample_rate(),\n            output_rate: output_sample_rate,\n        };\n        let inp_buf = vec![0.0; BUFFER_SIZE];\n        let out_buf = vec![0.0; spec.output_size(BUFFER_SIZE)];\n        Self {\n            resampler: AudioResampler::new(quality, spec).unwrap(),\n            source,\n            inp: Buf {\n                buf: inp_buf,\n                start: 0,\n                end: 0,\n            },\n            out: Buf {\n                buf: out_buf,\n                start: 0,\n                end: 0,\n            },\n        }\n    }\n}\n\nimpl<S> AudioSource for ResampledSource<S>\nwhere\n    S: AudioSource,\n{\n    fn write(&mut self, output: &mut [f32]) -> usize {\n        let mut total = 0;\n\n        while total < output.len() {\n            if self.out.is_empty() {\n                if self.inp.is_empty() {\n                    let n = self.source.write(&mut self.inp.buf);\n                    self.inp.buf[n..].iter_mut().for_each(|s| *s = 0.0);\n                    self.inp.start = 0;\n                    self.inp.end = self.inp.buf.len();\n                }\n                let (inp_consumed, out_written) = self\n                    .resampler\n                    .process(&self.inp.buf[self.inp.start..], &mut self.out.buf)\n                    .unwrap();\n                self.inp.start += inp_consumed;\n                self.out.start = 0;\n                self.out.end = out_written;\n            }\n            let source = self.out.get();\n            let target = &mut output[total..];\n            let to_write = self.out.len().min(target.len());\n            target[..to_write].copy_from_slice(&source[..to_write]);\n            total += to_write;\n            self.out.start += to_write;\n        }\n\n        total\n    }\n\n    fn channel_count(&self) -> usize {\n        self.resampler.spec.channels\n    }\n\n    fn sample_rate(&self) -> u32 {\n        self.resampler.spec.output_rate\n    }\n}\n\nstruct Buf {\n    buf: Vec<f32>,\n    start: usize,\n    end: usize,\n}\n\nimpl Buf {\n    fn get(&self) -> &[f32] {\n        &self.buf[self.start..self.end]\n    }\n\n    fn len(&self) -> usize {\n        self.end - self.start\n    }\n\n    fn is_empty(&self) -> bool {\n        self.start >= self.end\n    }\n}\n"
  },
  {
    "path": "psst-core/src/cache.rs",
    "content": "use std::{\n    fs, io,\n    path::{Path, PathBuf},\n    sync::Arc,\n};\n\nuse crate::{\n    audio::decrypt::AudioKey,\n    error::Error,\n    item_id::{FileId, ItemId},\n};\n\nuse librespot_protocol::metadata::{Episode, Track};\nuse protobuf::Message;\n\npub type CacheHandle = Arc<Cache>;\n\n#[derive(Debug)]\npub struct Cache {\n    base: PathBuf,\n}\n\nfn create_cache_dirs(base: &Path) -> io::Result<()> {\n    mkdir_if_not_exists(base)?;\n    mkdir_if_not_exists(&base.join(\"track\"))?;\n    mkdir_if_not_exists(&base.join(\"episode\"))?;\n    mkdir_if_not_exists(&base.join(\"audio\"))?;\n    mkdir_if_not_exists(&base.join(\"key\"))?;\n    Ok(())\n}\n\nimpl Cache {\n    pub fn new(base: PathBuf) -> Result<CacheHandle, Error> {\n        log::info!(\"using cache: {base:?}\");\n\n        // Create the cache structure.\n        create_cache_dirs(&base)?;\n\n        let cache = Self { base };\n        Ok(Arc::new(cache))\n    }\n\n    pub fn clear(&self) -> io::Result<()> {\n        log::info!(\"clearing cache: {:?}\", self.base);\n\n        for entry in fs::read_dir(&self.base)? {\n            let entry = entry?;\n            let path = entry.path();\n            if path.is_dir() {\n                fs::remove_dir_all(path)?;\n            } else {\n                fs::remove_file(path)?;\n            }\n        }\n\n        // Re-create the essential directory structure.\n        create_cache_dirs(&self.base)\n    }\n}\n\n// Cache of `Track` protobuf structures.\nimpl Cache {\n    pub fn get_track(&self, item_id: ItemId) -> Option<Track> {\n        let buf = fs::read(self.track_path(item_id)).ok()?;\n        Track::parse_from_bytes(&buf).ok()\n    }\n\n    pub fn save_track(&self, item_id: ItemId, track: &Track) -> Result<(), Error> {\n        log::debug!(\"saving track to cache: {item_id:?}\");\n        fs::write(self.track_path(item_id), track.write_to_bytes()?)?;\n        Ok(())\n    }\n\n    fn track_path(&self, item_id: ItemId) -> PathBuf {\n        self.base.join(\"track\").join(item_id.to_base62())\n    }\n}\n\n// Cache of `Episode` protobuf structures.\nimpl Cache {\n    pub fn get_episode(&self, item_id: ItemId) -> Option<Episode> {\n        let buf = fs::read(self.episode_path(item_id)).ok()?;\n        Episode::parse_from_bytes(&buf).ok()\n    }\n\n    pub fn save_episode(&self, item_id: ItemId, episode: &Episode) -> Result<(), Error> {\n        log::debug!(\"saving episode to cache: {item_id:?}\");\n        fs::write(self.episode_path(item_id), episode.write_to_bytes()?)?;\n        Ok(())\n    }\n\n    fn episode_path(&self, item_id: ItemId) -> PathBuf {\n        self.base.join(\"episode\").join(item_id.to_base62())\n    }\n}\n\n// Cache of `AudioKey`s.\nimpl Cache {\n    pub fn get_audio_key(&self, item_id: ItemId, file_id: FileId) -> Option<AudioKey> {\n        let buf = fs::read(self.audio_key_path(item_id, file_id)).ok()?;\n        AudioKey::from_raw(&buf)\n    }\n\n    pub fn save_audio_key(\n        &self,\n        item_id: ItemId,\n        file_id: FileId,\n        key: &AudioKey,\n    ) -> Result<(), Error> {\n        log::debug!(\"saving audio key to cache: {item_id:?}:{file_id:?}\");\n        fs::write(self.audio_key_path(item_id, file_id), key.0)?;\n        Ok(())\n    }\n\n    fn audio_key_path(&self, item_id: ItemId, file_id: FileId) -> PathBuf {\n        let mut key_id = String::new();\n        key_id += &item_id.to_base62()[..16];\n        key_id += &file_id.to_base16()[..16];\n        self.base.join(\"key\").join(key_id)\n    }\n}\n\n// Cache of encrypted audio file content.\nimpl Cache {\n    pub fn audio_file_path(&self, file_id: FileId) -> PathBuf {\n        self.base.join(\"audio\").join(file_id.to_base16())\n    }\n\n    pub fn save_audio_file(&self, file_id: FileId, from_path: PathBuf) -> Result<(), Error> {\n        log::debug!(\"saving audio file to cache: {file_id:?}\");\n        fs::copy(from_path, self.audio_file_path(file_id))?;\n        Ok(())\n    }\n}\n\n// Cache of user country code.\nimpl Cache {\n    pub fn get_country_code(&self) -> Option<String> {\n        fs::read_to_string(self.country_code_path()).ok()\n    }\n\n    pub fn save_country_code(&self, country_code: &str) -> Result<(), Error> {\n        fs::write(self.country_code_path(), country_code)?;\n        Ok(())\n    }\n\n    fn country_code_path(&self) -> PathBuf {\n        self.base.join(\"country_code\")\n    }\n}\n\npub fn mkdir_if_not_exists(path: &Path) -> io::Result<()> {\n    fs::create_dir(path).or_else(|err| {\n        if err.kind() == io::ErrorKind::AlreadyExists {\n            Ok(())\n        } else {\n            Err(err)\n        }\n    })\n}\n"
  },
  {
    "path": "psst-core/src/cdn.rs",
    "content": "use std::{\n    io::Read,\n    sync::Arc,\n    time::{Duration, Instant},\n};\n\nuse serde::Deserialize;\n\nuse crate::{\n    error::Error,\n    item_id::FileId,\n    session::{SessionService},\n    util::default_ureq_agent_builder,\n};\nuse crate::session::login5::Login5;\n\npub type CdnHandle = Arc<Cdn>;\n\npub struct Cdn {\n    session: SessionService,\n    agent: ureq::Agent,\n    login5: Login5,\n}\n\nimpl Cdn {\n    pub fn new(session: SessionService, proxy_url: Option<&str>) -> Result<CdnHandle, Error> {\n        let agent = default_ureq_agent_builder(proxy_url).build();\n        Ok(Arc::new(Self {\n            session,\n            agent: agent.into(),\n            login5: Login5::new(None, proxy_url),\n        }))\n    }\n\n    pub fn resolve_audio_file_url(&self, id: FileId) -> Result<CdnUrl, Error> {\n        let locations_uri = format!(\n            \"https://api.spotify.com/v1/storage-resolve/files/audio/interactive/{}\",\n            id.to_base16()\n        );\n        let access_token = self.login5.get_access_token(&self.session)?;\n        let response = self\n            .agent\n            .get(&locations_uri)\n            .query(\"version\", \"10000000\")\n            .query(\"product\", \"9\")\n            .query(\"platform\", \"39\")\n            .query(\"alt\", \"json\")\n            .header(\"Authorization\", &format!(\"Bearer {}\", access_token.access_token))\n            .call()?;\n\n        #[derive(Deserialize)]\n        struct AudioFileLocations {\n            cdnurl: Vec<String>,\n        }\n\n        // Deserialize the response and pick a file URL from the returned CDN list.\n        let locations: AudioFileLocations = response.into_body().read_json()?;\n        let file_uri = locations\n            .cdnurl\n            .into_iter()\n            // TODO:\n            //  Now, we always pick the first URL in the list, figure out a better strategy.\n            //  Choosing by random seems wrong.\n            .next()\n            // TODO: Avoid panicking here.\n            .expect(\"No file URI found\");\n\n        let uri = CdnUrl::new(file_uri);\n        Ok(uri)\n    }\n\n    pub fn fetch_file_range(\n        &self,\n        uri: &str,\n        offset: u64,\n        length: u64,\n    ) -> Result<(u64, impl Read), Error> {\n        let response = self\n            .agent\n            .get(uri)\n            .header(\"Range\", &range_header(offset, length))\n            .call()?;\n        let total_length = parse_total_content_length(&response);\n        let data_reader = response.into_body().into_reader();\n        Ok((total_length, data_reader))\n    }\n}\n\n#[derive(Clone)]\npub struct CdnUrl {\n    pub url: String,\n    pub expires: Instant,\n}\n\nimpl CdnUrl {\n    // In case we fail to parse the expiration time from URL, this default is used.\n    const DEFAULT_EXPIRATION: Duration = Duration::from_secs(60 * 30);\n\n    // Consider URL expired even before the official expiration time.\n    const EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(5);\n\n    fn new(url: String) -> Self {\n        let expires_in = parse_expiration(&url).unwrap_or_else(|| {\n            log::warn!(\"failed to parse expiration time from URL {:?}\", &url);\n            Self::DEFAULT_EXPIRATION\n        });\n        let expires = Instant::now() + expires_in;\n        Self { url, expires }\n    }\n\n    pub fn is_expired(&self) -> bool {\n        self.expires.saturating_duration_since(Instant::now()) < Self::EXPIRATION_TIME_THRESHOLD\n    }\n}\n\nimpl From<ureq::Error> for Error {\n    fn from(err: ureq::Error) -> Self {\n        Error::AudioFetchingError(Box::new(err))\n    }\n}\n\n/// Constructs a Range header value for given offset and length.\nfn range_header(offfset: u64, length: u64) -> String {\n    let last_byte = offfset + length - 1; // Offset of the last byte of the range is inclusive.\n    format!(\"bytes={offfset}-{last_byte}\")\n}\n\n/// Parses a total content length from a Content-Range response header.\n///\n/// For example, returns 146515 for a response with header\n/// \"Content-Range: bytes 0-1023/146515\".\nfn parse_total_content_length(response: &ureq::http::response::Response<ureq::Body>) -> u64 {\n    response\n        .headers()\n        .get(\"Content-Range\")\n        .expect(\"Content-Range header not found\")\n        .to_str()\n        .expect(\"Failed to parse Content-Range Header\")\n        .split('/')\n        .next_back()\n        .expect(\"Failed to parse Content-Range Header\")\n        .parse()\n        .expect(\"Failed to parse Content-Range Header\")\n}\n\n/// Parses an expiration of an audio file URL.\nfn parse_expiration(url: &str) -> Option<Duration> {\n    let token_exp = url.split(\"__token__=exp=\").nth(1);\n    let expires_millis = if let Some(token_exp) = token_exp {\n        // Parse from the expiration token param\n        token_exp.split('~').next()?\n    } else if let Some(verify_exp) = url.split(\"verify=\").nth(1) {\n        // Parse from verify parameter (new spotifycdn.com format)\n        verify_exp.split('-').next()?\n    } else {\n        // Parse from the first param\n        let first_param = url.split('?').nth(1)?;\n        first_param.split('_').next()?\n    };\n    let expires_millis = expires_millis.parse().ok()?;\n    let expires = Duration::from_millis(expires_millis);\n    Some(expires)\n}\n"
  },
  {
    "path": "psst-core/src/connection/diffie_hellman.rs",
    "content": "use num_bigint::{BigUint, ToBigUint};\nuse rand::Rng;\n\npub struct DHLocalKeys {\n    private_key: BigUint,\n    public_key: BigUint,\n}\n\nimpl DHLocalKeys {\n    pub fn random() -> DHLocalKeys {\n        let private_key = rand::rng().random::<u32>().to_biguint().unwrap();\n        let public_key = dh_generator().modpow(&private_key, &dh_prime());\n        DHLocalKeys {\n            private_key,\n            public_key,\n        }\n    }\n\n    pub fn public_key(&self) -> Vec<u8> {\n        self.public_key.to_bytes_be()\n    }\n\n    pub fn shared_secret(&self, remote_key: &[u8]) -> Vec<u8> {\n        let remote_key = BigUint::from_bytes_be(remote_key);\n        let shared_key = remote_key.modpow(&self.private_key, &dh_prime());\n        shared_key.to_bytes_be()\n    }\n}\n\nfn dh_generator() -> BigUint {\n    BigUint::from(0x2_u64)\n}\n\nfn dh_prime() -> BigUint {\n    BigUint::from_bytes_be(&[\n        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2,\n        0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67,\n        0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e,\n        0x34, 0x04, 0xdd, 0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d,\n        0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45, 0xe4, 0x85, 0xb5,\n        0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff,\n        0xff, 0xff, 0xff, 0xff, 0xff, 0xff,\n    ])\n}\n"
  },
  {
    "path": "psst-core/src/connection/mod.rs",
    "content": "pub mod diffie_hellman;\npub mod shannon_codec;\n\nuse std::{\n    convert::TryInto,\n    io,\n    io::{Read, Write},\n    net::{TcpStream, ToSocketAddrs},\n};\n\nuse byteorder::{ReadBytesExt, BE};\nuse hmac::{Hmac, Mac};\nuse serde::{Deserialize, Serialize};\nuse sha1::Sha1;\nuse socks::Socks5Stream;\nuse url::Url;\n\nuse crate::{\n    connection::{\n        diffie_hellman::DHLocalKeys,\n        shannon_codec::{ShannonDecoder, ShannonEncoder, ShannonMsg},\n    },\n    error::Error,\n    util::{default_ureq_agent_builder, NET_CONNECT_TIMEOUT, NET_IO_TIMEOUT},\n};\n\nuse librespot_protocol::authentication::AuthenticationType;\nuse protobuf::{Enum, Message, MessageField, SpecialFields};\n\n// Device ID used for authentication message.\nconst DEVICE_ID: &str = \"Psst\";\n\n// URI of access-point resolve endpoint.\nconst AP_RESOLVE_ENDPOINT: &str = \"http://apresolve.spotify.com\";\n\n// Access-point used in case the resolving fails.\nconst AP_FALLBACK: &str = \"ap.spotify.com:443\";\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\n#[serde(from = \"SerializedCredentials\")]\n#[serde(into = \"SerializedCredentials\")]\npub struct Credentials {\n    pub username: Option<String>,\n    pub auth_data: Vec<u8>,\n    pub auth_type: AuthenticationType,\n}\n\nimpl Credentials {\n    pub fn from_username_and_password(username: String, password: String) -> Self {\n        Self {\n            username: Some(username),\n            auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,\n            auth_data: password.into_bytes(),\n        }\n    }\n\n    pub fn from_access_token(token: String) -> Self {\n        Self {\n            username: None,\n            auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,\n            auth_data: token.into_bytes(),\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize)]\nstruct SerializedCredentials {\n    username: String,\n    auth_data: String,\n    auth_type: i32,\n}\n\nimpl From<SerializedCredentials> for Credentials {\n    fn from(value: SerializedCredentials) -> Self {\n        Self {\n            username: Some(value.username),\n            auth_data: value.auth_data.into_bytes(),\n            auth_type: AuthenticationType::from_i32(value.auth_type).unwrap_or_default(),\n        }\n    }\n}\n\nimpl From<Credentials> for SerializedCredentials {\n    fn from(value: Credentials) -> Self {\n        Self {\n            username: value.username.unwrap_or_default(),\n            auth_data: String::from_utf8(value.auth_data)\n                .expect(\"Invalid UTF-8 in serialized credentials\"),\n            auth_type: value.auth_type as _,\n        }\n    }\n}\n\npub struct Transport {\n    pub stream: TcpStream,\n    pub encoder: ShannonEncoder<TcpStream>,\n    pub decoder: ShannonDecoder<TcpStream>,\n}\n\nimpl Transport {\n    pub fn resolve_ap_with_fallback(proxy_url: Option<&str>) -> Vec<String> {\n        match Self::resolve_ap(proxy_url) {\n            Ok(ap_list) => {\n                log::info!(\"successfully resolved {} access points\", ap_list.len());\n                ap_list\n            }\n            Err(err) => {\n                log::error!(\"error while resolving APs, using fallback: {err:?}\");\n                vec![AP_FALLBACK.into()]\n            }\n        }\n    }\n\n    pub fn resolve_ap(proxy_url: Option<&str>) -> Result<Vec<String>, Error> {\n        #[derive(Clone, Debug, Deserialize)]\n        struct APResolveData {\n            ap_list: Vec<String>,\n        }\n\n        let agent: ureq::Agent = default_ureq_agent_builder(proxy_url).build().into();\n        log::info!(\"requesting AP list from {AP_RESOLVE_ENDPOINT}\");\n        let data: APResolveData = agent\n            .get(AP_RESOLVE_ENDPOINT)\n            .call()?\n            .into_body()\n            .read_json()?;\n        if data.ap_list.is_empty() {\n            log::warn!(\"received empty AP list from server\");\n            Err(Error::UnexpectedResponse)\n        } else {\n            log::info!(\"received {} APs from server\", data.ap_list.len());\n            Ok(data.ap_list)\n        }\n    }\n\n    pub fn connect(ap_list: &[String], proxy_url: Option<&str>) -> Result<Self, Error> {\n        log::info!(\n            \"attempting to connect using {} access points\",\n            ap_list.len()\n        );\n        for (index, ap) in ap_list.iter().enumerate() {\n            log::info!(\"trying AP {} of {}: {}\", index + 1, ap_list.len(), ap);\n            let stream = if let Some(url) = proxy_url {\n                match Self::stream_through_proxy(ap, url) {\n                    Ok(s) => s,\n                    Err(e) => {\n                        log::warn!(\"failed to connect to AP {ap} through proxy: {e:?}\");\n                        continue;\n                    }\n                }\n            } else {\n                match Self::stream_without_proxy(ap) {\n                    Ok(s) => s,\n                    Err(e) => {\n                        log::warn!(\"failed to connect to AP {ap} without proxy: {e:?}\");\n                        continue;\n                    }\n                }\n            };\n            if let Err(err) = stream.set_write_timeout(Some(NET_IO_TIMEOUT)) {\n                log::warn!(\"failed to set TCP write timeout: {err:?}\");\n            }\n            log::info!(\"successfully connected to AP: {ap}\");\n            return Self::exchange_keys(stream);\n        }\n        log::error!(\"failed to connect to any access point\");\n        Err(Error::ConnectionFailed)\n    }\n\n    fn stream_without_proxy(ap: &str) -> Result<TcpStream, io::Error> {\n        let mut last_err = None;\n        for addr in ap.to_socket_addrs()? {\n            match TcpStream::connect_timeout(&addr, NET_CONNECT_TIMEOUT) {\n                Ok(stream) => {\n                    return Ok(stream);\n                }\n                Err(err) => {\n                    last_err.replace(err);\n                }\n            }\n        }\n        Err(last_err.unwrap_or_else(|| {\n            io::Error::new(\n                io::ErrorKind::InvalidInput,\n                \"could not resolve to any addresses\",\n            )\n        }))\n    }\n\n    fn stream_through_proxy(ap: &str, url: &str) -> Result<TcpStream, Error> {\n        match Url::parse(url) {\n            Ok(url) if url.scheme() == \"socks\" || url.scheme() == \"socks5\" => {\n                // Currently we only support SOCKS5 proxies.\n                Self::stream_through_socks5_proxy(ap, &url)\n            }\n            _ => {\n                // Proxy URL failed to parse or has unsupported scheme.\n                Err(Error::ProxyUrlInvalid)\n            }\n        }\n    }\n\n    fn stream_through_socks5_proxy(ap: &str, url: &Url) -> Result<TcpStream, Error> {\n        let addrs = url.socket_addrs(|| None)?;\n        let username = url.username();\n        let password = url.password().unwrap_or(\"\");\n        // TODO: `socks` crate does not support connection timeouts.\n        let proxy = if username.is_empty() {\n            Socks5Stream::connect(&addrs[..], ap)?\n        } else {\n            Socks5Stream::connect_with_password(&addrs[..], ap, username, password)?\n        };\n        Ok(proxy.into_inner())\n    }\n\n    pub fn exchange_keys(mut stream: TcpStream) -> Result<Self, Error> {\n        use librespot_protocol::keyexchange::APResponseMessage;\n\n        let local_keys = DHLocalKeys::random();\n\n        // Start by sending the hello message with our public key and nonce.\n        log::trace!(\"sending client hello\");\n        let client_nonce: [u8; 16] = rand::random();\n        let hello = client_hello(local_keys.public_key(), client_nonce.into());\n        let hello_packet = make_packet(&[0, 4], &hello);\n        stream.write_all(&hello_packet)?;\n        log::trace!(\"sent client hello\");\n\n        // Wait for the response packet with the remote public key.  Note that we are\n        // keeping both the hello packet and the response packet for later (they get\n        // hashed together with the shared secret to make a key pair).\n        log::trace!(\"waiting for AP response\");\n        let apresp_packet = read_packet(&mut stream)?;\n        let apresp = APResponseMessage::parse_from_bytes(&apresp_packet[4..])?;\n        log::trace!(\"received AP response\");\n\n        // Compute the challenge response and the sending/receiving keys.\n        let remote_key = apresp\n            .challenge\n            .login_crypto_challenge\n            .diffie_hellman\n            .gs\n            .as_ref()\n            .expect(\"Missing data\");\n\n        let (challenge, send_key, recv_key) = compute_keys(\n            &local_keys.shared_secret(remote_key),\n            &hello_packet,\n            &apresp_packet,\n        );\n\n        // Respond with the computed HMAC and finish the handshake.\n        log::trace!(\"sending client response\");\n        let response = client_response_plaintext(challenge);\n        let response_packet = make_packet(&[], &response);\n        stream.write_all(&response_packet)?;\n        log::trace!(\"sent client response\");\n\n        // Use the derived keys to make a codec, wrapping the TCP stream.\n        let encoder = ShannonEncoder::new(stream.try_clone()?, &send_key);\n        let decoder = ShannonDecoder::new(stream.try_clone()?, &recv_key);\n\n        Ok(Self {\n            stream,\n            encoder,\n            decoder,\n        })\n    }\n\n    pub fn authenticate(&mut self, credentials: Credentials) -> Result<Credentials, Error> {\n        use librespot_protocol::{authentication::APWelcome, keyexchange::APLoginFailed};\n\n        // Send a login request with the client credentials.\n        let request = client_response_encrypted(credentials);\n        self.encoder.encode(request)?;\n\n        // Expect an immediate response with the authentication result.\n        let response = self.decoder.decode()?;\n\n        match response.cmd {\n            ShannonMsg::AP_WELCOME => {\n                let welcome_data =\n                    APWelcome::parse_from_bytes(&response.payload).expect(\"Missing data\");\n\n                Ok(Credentials {\n                    username: Some(welcome_data.canonical_username().to_string()),\n                    auth_data: welcome_data.reusable_auth_credentials().to_vec(),\n                    auth_type: welcome_data.reusable_auth_credentials_type(),\n                })\n            }\n            ShannonMsg::AUTH_FAILURE => {\n                let error_data =\n                    APLoginFailed::parse_from_bytes(&response.payload).expect(\"Missing data\");\n                Err(Error::AuthFailed {\n                    code: error_data.error_code() as _,\n                })\n            }\n            _ => {\n                unreachable!(\"unexpected message\");\n            }\n        }\n    }\n}\n\nfn read_packet(stream: &mut TcpStream) -> io::Result<Vec<u8>> {\n    let size = stream.read_u32::<BE>()?;\n    let mut buf = vec![0_u8; size as usize];\n    let (size_buf, data_buf) = buf.split_at_mut(4);\n    size_buf.copy_from_slice(&size.to_be_bytes());\n    stream.read_exact(data_buf)?;\n    Ok(buf)\n}\n\nfn make_packet(prefix: &[u8], data: &[u8]) -> Vec<u8> {\n    let size = prefix.len() + 4 + data.len();\n    let mut buf = Vec::with_capacity(size);\n    let size_u32: u32 = size.try_into().unwrap();\n    buf.extend(prefix);\n    buf.extend(size_u32.to_be_bytes());\n    buf.extend(data);\n    buf\n}\n\nfn client_hello(public_key: Vec<u8>, nonce: Vec<u8>) -> Vec<u8> {\n    use librespot_protocol::keyexchange::*;\n\n    let hello = ClientHello {\n        build_info: MessageField::some(BuildInfo {\n            platform: Some(Platform::PLATFORM_LINUX_X86.into()),\n            product: Some(Product::PRODUCT_PARTNER.into()),\n            product_flags: vec![],\n            version: Some(109_800_078),\n            special_fields: SpecialFields::new(),\n        }),\n        cryptosuites_supported: vec![Cryptosuite::CRYPTO_SUITE_SHANNON.into()],\n        fingerprints_supported: vec![],\n        powschemes_supported: vec![],\n        login_crypto_hello: MessageField::some(LoginCryptoHelloUnion {\n            diffie_hellman: MessageField::some(LoginCryptoDiffieHellmanHello {\n                gc: Some(public_key),\n                server_keys_known: Some(1),\n                special_fields: SpecialFields::new(),\n            }),\n            special_fields: SpecialFields::new(),\n        }),\n        client_nonce: Some(nonce),\n        padding: Some(vec![0x1e]),\n        feature_set: None.into(),\n        special_fields: SpecialFields::new(),\n    };\n\n    hello\n        .write_to_bytes()\n        .expect(\"Failed to serialize client hello\")\n}\n\nfn client_response_plaintext(challenge: Vec<u8>) -> Vec<u8> {\n    use librespot_protocol::keyexchange::*;\n\n    let response = ClientResponsePlaintext {\n        login_crypto_response: MessageField::some(LoginCryptoResponseUnion {\n            diffie_hellman: MessageField::some(LoginCryptoDiffieHellmanResponse {\n                hmac: Some(challenge),\n                special_fields: SpecialFields::new(),\n            }),\n            special_fields: SpecialFields::new(),\n        }),\n        pow_response: MessageField::some(PoWResponseUnion::default()),\n        crypto_response: MessageField::some(CryptoResponseUnion::default()),\n        special_fields: SpecialFields::new(),\n    };\n\n    response\n        .write_to_bytes()\n        .expect(\"Failed to serialize client response\")\n}\n\nfn compute_keys(\n    shared_secret: &[u8],\n    hello_packet: &[u8],\n    apresp_packet: &[u8],\n) -> (Vec<u8>, Vec<u8>, Vec<u8>) {\n    let mut data = Vec::with_capacity(5 * 20);\n    for i in 1..6 {\n        let mut mac: Hmac<Sha1> =\n            Hmac::new_from_slice(shared_secret).expect(\"HMAC can take key of any size\");\n        mac.update(hello_packet);\n        mac.update(apresp_packet);\n        mac.update(&[i]);\n        data.extend(mac.finalize().into_bytes());\n    }\n    let mut mac: Hmac<Sha1> =\n        Hmac::new_from_slice(&data[..20]).expect(\"HMAC can take key of any size\");\n    mac.update(hello_packet);\n    mac.update(apresp_packet);\n    let digest = mac.finalize().into_bytes();\n\n    (\n        (*digest).to_vec(),\n        data[20..52].to_vec(),\n        data[52..84].to_vec(),\n    )\n}\n\nfn client_response_encrypted(credentials: Credentials) -> ShannonMsg {\n    use librespot_protocol::authentication::{\n        ClientResponseEncrypted, LoginCredentials, SystemInfo, Os, CpuFamily\n    };\n\n    let response = ClientResponseEncrypted {\n        login_credentials: MessageField::some(LoginCredentials {\n            username: credentials.username,\n            auth_data: Some(credentials.auth_data),\n            typ: Some(credentials.auth_type.into()),\n            special_fields: SpecialFields::new(),\n        }),\n        system_info: MessageField::some(SystemInfo {\n            device_id: Some(DEVICE_ID.to_string()),\n            system_information_string: Some(\"librespot_but_actually_psst\".to_string()),\n            os: Some(Os::default().into()),\n            cpu_family: Some(CpuFamily::default().into()),\n            ..SystemInfo::default()\n        }),\n        ..ClientResponseEncrypted::default()\n    };\n\n    let buf = response.write_to_bytes().expect(\"Failed to serialize\");\n    ShannonMsg::new(ShannonMsg::LOGIN, buf)\n}\n"
  },
  {
    "path": "psst-core/src/connection/shannon_codec.rs",
    "content": "use std::{convert::TryInto, io};\n\nuse shannon::Shannon;\n\n#[derive(Debug)]\npub struct ShannonMsg {\n    pub cmd: u8,\n    pub payload: Vec<u8>,\n}\n\nimpl ShannonMsg {\n    pub const SECRET_BLOCK: u8 = 0x02;\n    pub const PING: u8 = 0x04;\n    pub const STREAM_CHUNK: u8 = 0x08;\n    pub const STREAM_CHUNK_RES: u8 = 0x09;\n    pub const CHANNEL_ERROR: u8 = 0x0a;\n    pub const CHANNEL_ABORT: u8 = 0x0b;\n    pub const REQUEST_KEY: u8 = 0x0c;\n    pub const AES_KEY: u8 = 0x0d;\n    pub const AES_KEY_ERROR: u8 = 0x0e;\n    pub const IMAGE: u8 = 0x19;\n    pub const COUNTRY_CODE: u8 = 0x1b;\n    pub const PONG: u8 = 0x49;\n    pub const PONG_ACK: u8 = 0x4a;\n    pub const PAUSE: u8 = 0x4b;\n    pub const PRODUCT_INFO: u8 = 0x50;\n    pub const LEGACY_WELCOME: u8 = 0x69;\n    pub const LICENSE_VERSION: u8 = 0x76;\n    pub const LOGIN: u8 = 0xab;\n    pub const AP_WELCOME: u8 = 0xac;\n    pub const AUTH_FAILURE: u8 = 0xad;\n    pub const MERCURY_REQ: u8 = 0xb2;\n    pub const MERCURY_SUB: u8 = 0xb3;\n    pub const MERCURY_UNSUB: u8 = 0xb4;\n    pub const MERCURY_PUB: u8 = 0xb5;\n\n    pub fn new(cmd: u8, payload: impl Into<Vec<u8>>) -> Self {\n        Self {\n            cmd,\n            payload: payload.into(),\n        }\n    }\n}\n\nconst MAC_SIZE: usize = 4;\nconst HEADER_SIZE: usize = 3;\n\npub struct ShannonEncoder<T> {\n    inner: T,\n    nonce: u32,\n    cipher: Shannon,\n}\n\nimpl<T> ShannonEncoder<T>\nwhere\n    T: io::Write,\n{\n    pub fn new(inner: T, send_key: &[u8]) -> Self {\n        Self {\n            inner,\n            nonce: 0,\n            cipher: Shannon::new(send_key),\n        }\n    }\n\n    pub fn encode(&mut self, item: ShannonMsg) -> io::Result<()> {\n        // Buffer up the whole message.\n        let mut buf = Vec::with_capacity(HEADER_SIZE + item.payload.len() + MAC_SIZE);\n        let len_u16: u16 = item.payload.len().try_into().unwrap();\n        buf.push(item.cmd);\n        buf.extend(len_u16.to_be_bytes());\n        buf.extend(item.payload);\n\n        // Seed the cipher, rotate the nonce, and encrypt the header and payload.\n        self.cipher.nonce_u32(self.nonce);\n        self.nonce += 1;\n        self.cipher.encrypt(&mut buf);\n\n        // Compute the MAC and append it.\n        let mut mac = [0_u8; MAC_SIZE];\n        self.cipher.finish(&mut mac);\n        buf.extend(mac);\n\n        self.inner.write_all(&buf)\n    }\n\n    pub fn as_inner_mut(&mut self) -> &mut T {\n        &mut self.inner\n    }\n}\n\npub struct ShannonDecoder<T> {\n    inner: T,\n    nonce: u32,\n    cipher: Shannon,\n}\n\nimpl<T> ShannonDecoder<T>\nwhere\n    T: io::Read,\n{\n    pub fn new(inner: T, recv_key: &[u8]) -> Self {\n        Self {\n            inner,\n            nonce: 0,\n            cipher: Shannon::new(recv_key),\n        }\n    }\n\n    pub fn decode(&mut self) -> io::Result<ShannonMsg> {\n        // Seed the cipher and rotate the nonce.\n        self.cipher.nonce_u32(self.nonce);\n        self.nonce += 1;\n\n        // Read the whole header.  Reading and decrypting byte by byte is not really\n        // reliable, because of a bug in `shannon` crate.\n        let mut header = [0_u8; HEADER_SIZE];\n        self.inner.read_exact(&mut header)?;\n        self.cipher.decrypt(&mut header);\n\n        // Parse the header fields.\n        let cmd = header[0];\n        let size = u16::from_be_bytes([header[1], header[2]]) as usize;\n\n        // Read and decrypt the payload.\n        let mut payload = vec![0_u8; size];\n        self.inner.read_exact(&mut payload)?;\n        self.cipher.decrypt(&mut payload);\n\n        // Read and check the MAC.\n        let mut mac = [0_u8; MAC_SIZE];\n        self.inner.read_exact(&mut mac)?;\n        self.cipher.check_mac(&mac)?;\n\n        Ok(ShannonMsg::new(cmd, payload))\n    }\n\n    pub fn as_inner(&self) -> &T {\n        &self.inner\n    }\n}\n"
  },
  {
    "path": "psst-core/src/error.rs",
    "content": "use std::sync::mpsc::RecvTimeoutError;\nuse std::{error, fmt, io};\n\n#[derive(Debug)]\npub enum Error {\n    SessionDisconnected,\n    UnexpectedResponse,\n    MediaFileNotFound,\n    ProxyUrlInvalid,\n    AuthFailed { code: i32 },\n    ConnectionFailed,\n    JsonError(Box<dyn error::Error + Send>),\n    InvalidStateError(Box<dyn error::Error + Send + Sync>),\n    UnimplementedError(Box<dyn error::Error + Send + Sync>),\n    AudioFetchingError(Box<dyn error::Error + Send>),\n    AudioDecodingError(Box<dyn error::Error + Send>),\n    AudioOutputError(Box<dyn error::Error + Send>),\n    AudioProbeError(Box<dyn error::Error + Send>),\n    ScrobblerError(Box<dyn error::Error + Send>),\n    ResamplingError(i32),\n    ConfigError(String),\n    IoError(io::Error),\n    SendError,\n    RecvTimeoutError(RecvTimeoutError),\n    JoinError,\n    OAuthError(String),\n}\n\nimpl error::Error for Error {}\n\nimpl fmt::Display for Error {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::SessionDisconnected => write!(f, \"Session disconnected\"),\n            Self::UnexpectedResponse => write!(f, \"Unknown server response\"),\n            Self::MediaFileNotFound => write!(f, \"Audio file not found\"),\n            Self::ProxyUrlInvalid => write!(f, \"Invalid proxy URL\"),\n            Self::AuthFailed { code } => match code {\n                0 => write!(f, \"Authentication failed: protocol error\"),\n                2 => write!(f, \"Authentication failed: try another AP\"),\n                5 => write!(f, \"Authentication failed: bad connection id\"),\n                9 => write!(f, \"Authentication failed: travel restriction\"),\n                11 => write!(f, \"Authentication failed: premium account required\"),\n                12 => write!(f, \"Authentication failed: bad credentials\"),\n                13 => write!(f, \"Authentication failed: could not validate credentials\"),\n                14 => write!(f, \"Authentication failed: account exists\"),\n                15 => write!(f, \"Authentication failed: extra verification required\"),\n                16 => write!(f, \"Authentication failed: invalid app key\"),\n                17 => write!(f, \"Authentication failed: application banned\"),\n                _ => write!(f, \"Authentication failed with error code {code}\"),\n            },\n            Self::ConnectionFailed => write!(f, \"Failed to connect to any access point\"),\n            Self::ResamplingError(code) => {\n                write!(f, \"Resampling failed with error code {code}\")\n            }\n            Self::ConfigError(msg) => write!(f, \"Configuration error: {msg}\"),\n            Self::JsonError(err)\n            | Self::AudioFetchingError(err)\n            | Self::AudioDecodingError(err)\n            | Self::AudioOutputError(err)\n            | Self::ScrobblerError(err)\n            | Self::AudioProbeError(err) => err.fmt(f),\n            Self::InvalidStateError(err)\n            | Self::UnimplementedError(err) => err.fmt(f),\n            Self::IoError(err) => err.fmt(f),\n            Self::SendError => write!(f, \"Failed to send into a channel\"),\n            Self::RecvTimeoutError(err) => write!(f, \"Channel receive timeout: {err}\"),\n            Self::JoinError => write!(f, \"Failed to join thread\"),\n            Self::OAuthError(msg) => write!(f, \"OAuth error: {msg}\"),\n        }\n    }\n}\n\nimpl From<io::Error> for Error {\n    fn from(err: io::Error) -> Error {\n        Error::IoError(err)\n    }\n}\n\nimpl<T> From<crossbeam_channel::SendError<T>> for Error {\n    fn from(_: crossbeam_channel::SendError<T>) -> Self {\n        Error::SendError\n    }\n}\n\nimpl From<RecvTimeoutError> for Error {\n    fn from(err: RecvTimeoutError) -> Self {\n        Error::RecvTimeoutError(err)\n    }\n}\n\nimpl From<protobuf::Error> for Error {\n    fn from(err: protobuf::Error) -> Self { Error::InvalidStateError(err.into()) }\n}"
  },
  {
    "path": "psst-core/src/item_id.rs",
    "content": "use std::{\n    collections::HashMap,\n    convert::TryInto,\n    fmt,\n    ops::Deref,\n    path::PathBuf,\n    sync::{LazyLock, Mutex},\n};\n\nstatic LOCAL_REGISTRY: LazyLock<Mutex<LocalItemRegistry>> =\n    LazyLock::new(|| Mutex::new(LocalItemRegistry::new()));\n\n// LocalItemRegistry allows generating IDs for local music files, so they can be\n// treated similarly to files hosted on Spotify's remote servers. IDs are\n// easier to pass around since they implement `Copy`, as opposed to passing\n// around a `PathBuf` or `File` pointing to the file.\n//\n// The registry stores two complementary maps for bi-directional lookup. This\n// allows for quick registration of new tracks and quick lookup of existing\n// tracks by ID, at the cost of increased memory usage. The ID-to-path lookup\n// should be prioritized, as that is required to begin playback. Path-to-ID\n// lookup is helpful to avoid registering the same path under multiple IDs,\n// but is okay to be a bit slower since it's only done once per track when\n// (when loading the list of local files from Spotify's config).\npub struct LocalItemRegistry {\n    next_id: u128,\n    path_to_id: HashMap<PathBuf, u128>,\n    id_to_path: HashMap<u128, PathBuf>,\n}\n\nimpl LocalItemRegistry {\n    fn new() -> Self {\n        Self {\n            next_id: 1,\n            path_to_id: HashMap::new(),\n            id_to_path: HashMap::new(),\n        }\n    }\n\n    pub fn get_or_insert(path: PathBuf) -> u128 {\n        let mut registry = LOCAL_REGISTRY.lock().unwrap();\n        registry.path_to_id.get(&path).copied().unwrap_or_else(|| {\n            let id = registry.next_id;\n            registry.next_id += 1;\n            registry.id_to_path.insert(id, path.clone());\n            id\n        })\n    }\n\n    pub fn get(id: u128) -> Option<PathBuf> {\n        let registry = LOCAL_REGISTRY.lock().unwrap();\n        registry.id_to_path.get(&id).cloned()\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum ItemIdType {\n    Track,\n    Podcast,\n    LocalFile,\n    Unknown,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub struct ItemId {\n    pub id: u128,\n    pub id_type: ItemIdType,\n}\n\nconst BASE62_DIGITS: &[u8] = b\"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\nconst BASE16_DIGITS: &[u8] = b\"0123456789abcdef\";\n\nimpl ItemId {\n    pub const INVALID: Self = Self::new(0u128, ItemIdType::Unknown);\n\n    pub const fn new(id: u128, id_type: ItemIdType) -> Self {\n        Self { id, id_type }\n    }\n\n    pub fn from_base16(id: &str, id_type: ItemIdType) -> Option<Self> {\n        let mut n = 0_u128;\n        for c in id.as_bytes() {\n            let d = BASE16_DIGITS.iter().position(|e| e == c)? as u128;\n            n *= 16;\n            n += d;\n        }\n        Some(Self::new(n, id_type))\n    }\n\n    pub fn from_base62(id: &str, id_type: ItemIdType) -> Option<Self> {\n        let mut n = 0_u128;\n        for c in id.as_bytes() {\n            let d = BASE62_DIGITS.iter().position(|e| e == c)? as u128;\n            n *= 62;\n            n += d;\n        }\n        Some(Self::new(n, id_type))\n    }\n\n    pub fn from_raw(data: &[u8], id_type: ItemIdType) -> Option<Self> {\n        let n = u128::from_be_bytes(data.try_into().ok()?);\n        Some(Self::new(n, id_type))\n    }\n\n    pub fn from_uri(uri: &str) -> Option<Self> {\n        let gid = uri.split(':').next_back()?;\n        if uri.contains(\":episode:\") {\n            Self::from_base62(gid, ItemIdType::Podcast)\n        } else if uri.contains(\":track:\") {\n            Self::from_base62(gid, ItemIdType::Track)\n        } else {\n            Self::from_base62(gid, ItemIdType::Unknown)\n        }\n    }\n\n    /// Converts an ID to an URI as described in: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids\n    pub fn to_uri(&self) -> Option<String> {\n        let b64 = self.to_base62();\n        match self.id_type {\n            ItemIdType::Track => Some(format!(\"spotify:track:{b64}\")),\n            ItemIdType::Podcast => Some(format!(\"spotify:podcast:{b64}\")),\n            // TODO: support adding local files to playlists\n            ItemIdType::LocalFile => None,\n            ItemIdType::Unknown => None,\n        }\n    }\n\n    pub fn to_base16(&self) -> String {\n        format!(\"{:032x}\", self.id)\n    }\n\n    pub fn to_base62(&self) -> String {\n        let mut n = self.id;\n        let mut data = [0_u8; 22];\n        for i in 0..22 {\n            data[21 - i] = BASE62_DIGITS[(n % 62) as usize];\n            n /= 62;\n        }\n        std::str::from_utf8(&data).unwrap().to_string()\n    }\n\n    pub fn to_raw(&self) -> [u8; 16] {\n        self.id.to_be_bytes()\n    }\n\n    pub fn from_local(path: PathBuf) -> Self {\n        Self::new(\n            LocalItemRegistry::get_or_insert(path),\n            ItemIdType::LocalFile,\n        )\n    }\n\n    pub fn to_local(&self) -> PathBuf {\n        match self.id_type {\n            // local items should only be constructed with `from_local`\n            ItemIdType::LocalFile => LocalItemRegistry::get(self.id).expect(\"valid item ID\"),\n            _ => panic!(\"expected local file\"),\n        }\n    }\n}\n\nimpl Default for ItemId {\n    fn default() -> Self {\n        Self::INVALID\n    }\n}\n\nimpl From<ItemId> for String {\n    fn from(id: ItemId) -> Self {\n        id.to_base62()\n    }\n}\n\n#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub struct FileId(pub [u8; 20]);\n\nimpl FileId {\n    pub fn from_raw(data: &[u8]) -> Option<Self> {\n        Some(FileId(data.try_into().ok()?))\n    }\n\n    pub fn to_base16(&self) -> String {\n        self.0\n            .iter()\n            .map(|b| format!(\"{b:02x}\"))\n            .collect::<Vec<String>>()\n            .concat()\n    }\n}\n\nimpl Deref for FileId {\n    type Target = [u8];\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl fmt::Debug for FileId {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        f.debug_tuple(\"FileId\").field(&self.to_base16()).finish()\n    }\n}\n\nimpl fmt::Display for FileId {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        f.write_str(&self.to_base16())\n    }\n}\n"
  },
  {
    "path": "psst-core/src/lastfm.rs",
    "content": "use crate::error::Error;\nuse crate::oauth::listen_for_callback_parameter;\nuse rustfm_scrobble::{responses::SessionResponse, Scrobble, Scrobbler, ScrobblerError};\nuse std::{net::SocketAddr, time::Duration};\nuse url::Url;\n\npub struct LastFmClient;\n\nimpl LastFmClient {\n    /// Report a track as \"now playing\" to Last.fm using an existing Scrobbler instance.\n    pub fn now_playing_song(\n        scrobbler: &Scrobbler, // Requires an authenticated Scrobbler\n        artist: &str,\n        title: &str,\n        album: Option<&str>,\n    ) -> Result<(), Error> {\n        let song = Scrobble::new(artist, title, album.unwrap_or(\"\"));\n        scrobbler\n            .now_playing(&song)\n            .map(|_| ())\n            .map_err(Error::from)\n    }\n\n    /// Scrobble a finished track to Last.fm using an existing Scrobbler instance.\n    pub fn scrobble_song(\n        scrobbler: &Scrobbler, // Requires an authenticated Scrobbler\n        artist: &str,\n        title: &str,\n        album: Option<&str>,\n    ) -> Result<(), Error> {\n        let song = Scrobble::new(artist, title, album.unwrap_or(\"\"));\n        scrobbler.scrobble(&song).map(|_| ()).map_err(Error::from)\n    }\n\n    /// Creates an authenticated Last.fm Scrobbler instance with provided credentials.\n    /// Note: This assumes the session_key is valid. Validity is checked on first API call.\n    pub fn create_scrobbler(\n        api_key: Option<&str>,\n        api_secret: Option<&str>,\n        session_key: Option<&str>,\n    ) -> Result<Scrobbler, Error> {\n        let (Some(api_key), Some(api_secret), Some(session_key)) =\n            (api_key, api_secret, session_key)\n        else {\n            log::warn!(\"missing Last.fm API key, secret, or session key for scrobbler creation.\");\n            return Err(Error::ConfigError(\n                \"Missing Last.fm API key, secret, or session key.\".to_string(),\n            ));\n        };\n\n        let mut scrobbler = Scrobbler::new(api_key, api_secret);\n        // Associate the session key with the scrobbler instance.\n        scrobbler.authenticate_with_session_key(session_key);\n        log::info!(\"scrobbler instance created with session key (validity checked on first use).\");\n        Ok(scrobbler)\n    }\n}\n\nimpl From<ScrobblerError> for Error {\n    fn from(value: ScrobblerError) -> Self {\n        Self::ScrobblerError(Box::new(value))\n    }\n}\n\n/// Generate a Last.fm authentication URL\npub fn generate_lastfm_auth_url(\n    api_key: &str,\n    callback_url: &str,\n) -> Result<String, url::ParseError> {\n    let base = \"http://www.last.fm/api/auth/\";\n    let url = Url::parse_with_params(base, &[(\"api_key\", api_key), (\"cb\", callback_url)])?;\n    Ok(url.to_string())\n}\n\n/// Exchange a token for a Last.fm session key\npub fn exchange_token_for_session(\n    api_key: &str,\n    api_secret: &str,\n    token: &str,\n) -> Result<String, Error> {\n    let mut scrobbler = Scrobbler::new(api_key, api_secret);\n    scrobbler\n        .authenticate_with_token(token) // Uses auth.getSession API call internally\n        .map(|response: SessionResponse| response.key) // Extract the session key string\n        .map_err(Error::from) // Map ScrobblerError to crate::error::Error\n}\n\n/// Listen for a Last.fm token from the callback\npub fn get_lastfm_token_listener(\n    socket_address: SocketAddr,\n    timeout: Duration,\n) -> Result<String, Error> {\n    // Use the shared listener function, specifying \"token\" as the parameter\n    listen_for_callback_parameter(socket_address, timeout, \"token\")\n}\n"
  },
  {
    "path": "psst-core/src/lib.rs",
    "content": "#![allow(clippy::new_without_default)]\n\nuse git_version::git_version;\n\npub const GIT_VERSION: &str = git_version!();\npub const BUILD_TIME: &str = include!(concat!(env!(\"OUT_DIR\"), \"/build-time.txt\"));\npub const REMOTE_URL: &str = include!(concat!(env!(\"OUT_DIR\"), \"/remote-url.txt\"));\n\npub mod actor;\npub mod audio;\npub mod cache;\npub mod cdn;\npub mod connection;\npub mod error;\npub mod item_id;\npub mod lastfm;\npub mod metadata;\npub mod oauth;\npub mod player;\npub mod session;\npub mod system_info;\npub mod util;\n"
  },
  {
    "path": "psst-core/src/metadata.rs",
    "content": "use std::time::Duration;\n\nuse crate::{\n    error::Error,\n    item_id::{FileId, ItemId, ItemIdType},\n    player::file::{AudioFormat, MediaFile, MediaPath},\n    session::SessionService,\n};\n\nuse librespot_protocol::metadata::restriction::Country_restriction;\nuse librespot_protocol::metadata::{AudioFile, Episode, Restriction, Track};\n\npub trait Fetch: protobuf::Message {\n    fn uri(id: ItemId) -> String;\n    fn fetch(session: &SessionService, id: ItemId) -> Result<Self, Error> {\n        session.connected()?.get_mercury_protobuf(Self::uri(id))\n    }\n}\n\nimpl Fetch for Track {\n    fn uri(id: ItemId) -> String {\n        format!(\"hm://metadata/3/track/{}\", id.to_base16())\n    }\n}\n\nimpl Fetch for Episode {\n    fn uri(id: ItemId) -> String {\n        format!(\"hm://metadata/3/episode/{}\", id.to_base16())\n    }\n}\n\npub trait ToMediaPath {\n    fn is_restricted_in_region(&self, country: &str) -> bool;\n    fn find_allowed_alternative(&self, country: &str) -> Option<ItemId>;\n    fn to_media_path(&self, preferred_bitrate: usize) -> Option<MediaPath>;\n}\n\nimpl ToMediaPath for Track {\n    fn is_restricted_in_region(&self, country: &str) -> bool {\n        self.restriction\n            .iter()\n            .any(|rest| is_restricted_in_region(rest, country))\n    }\n\n    fn find_allowed_alternative(&self, country: &str) -> Option<ItemId> {\n        let alt_track = self\n            .alternative\n            .iter()\n            .find(|alt_track| !alt_track.is_restricted_in_region(country))?;\n        ItemId::from_raw(alt_track.gid.as_ref()?, ItemIdType::Track)\n    }\n\n    fn to_media_path(&self, preferred_bitrate: usize) -> Option<MediaPath> {\n        let file = select_preferred_file(&self.file, preferred_bitrate)?;\n        Some(MediaPath {\n            item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Track)?,\n            file_id: FileId::from_raw(file.file_id.as_ref()?)?,\n            file_format: AudioFormat::from_protocol(file.format()),\n            duration: Duration::from_millis(self.duration? as u64),\n        })\n    }\n}\n\nimpl ToMediaPath for Episode {\n    fn is_restricted_in_region(&self, country: &str) -> bool {\n        self.restriction\n            .iter()\n            .any(|rest| is_restricted_in_region(rest, country))\n    }\n\n    fn find_allowed_alternative(&self, _country: &str) -> Option<ItemId> {\n        None\n    }\n\n    fn to_media_path(&self, preferred_bitrate: usize) -> Option<MediaPath> {\n        let file = select_preferred_file(&self.audio, preferred_bitrate)?;\n        Some(MediaPath {\n            item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Podcast)?,\n            file_id: FileId::from_raw(file.file_id.as_ref()?)?,\n            file_format: AudioFormat::from_protocol(file.format()),\n            duration: Duration::from_millis(self.duration? as u64),\n        })\n    }\n}\n\nfn select_preferred_file(files: &[AudioFile], preferred_bitrate: usize) -> Option<&AudioFile> {\n    MediaFile::supported_audio_formats_for_bitrate(preferred_bitrate)\n        .iter()\n        .find_map(|&preferred_format| {\n            files\n                .iter()\n                .find(|file| file.format == Some(preferred_format.into()))\n        })\n}\n\nfn is_restricted_in_region(restriction: &Restriction, country: &str) -> bool {\n    if let Some(list) = &restriction.country_restriction {\n        return match list {\n            Country_restriction::CountriesAllowed(allowed) => {\n                !is_country_in_list(allowed.as_bytes(), country.as_bytes())\n            }\n            Country_restriction::CountriesForbidden(forbidden) => {\n                is_country_in_list(forbidden.as_bytes(), country.as_bytes())\n            }\n            _ => false,\n        };\n    }\n    false\n}\n\nfn is_country_in_list(countries: &[u8], country: &[u8]) -> bool {\n    countries.chunks(2).any(|code| code == country)\n}\n"
  },
  {
    "path": "psst-core/src/oauth.rs",
    "content": "use crate::error::Error;\nuse oauth2::{\n    basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken,\n    PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,\n};\nuse std::{\n    io::{BufRead, BufReader, Write},\n    net::TcpStream,\n    net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},\n    sync::mpsc,\n    time::Duration,\n};\nuse url::Url;\n\npub fn listen_for_callback_parameter(\n    socket_address: SocketAddr,\n    timeout: Duration,\n    parameter_name: &'static str,\n) -> Result<String, Error> {\n    log::info!(\n        \"starting callback listener for '{parameter_name}' on {socket_address:?}\",\n    );\n\n    // Create a simpler, linear flow\n    // 1. Bind the listener\n    let listener = match TcpListener::bind(socket_address) {\n        Ok(l) => {\n            log::info!(\"listener bound successfully\");\n            l\n        }\n        Err(e) => {\n            log::error!(\"Failed to bind listener: {e}\");\n            return Err(Error::IoError(e));\n        }\n    };\n\n    // 2. Set up the channel for communication\n    let (tx, rx) = mpsc::channel::<Result<String, Error>>();\n\n    // 3. Spawn the thread\n    let handle = std::thread::spawn(move || {\n        if let Ok((mut stream, _)) = listener.accept() {\n            handle_callback_connection(&mut stream, &tx, parameter_name);\n        } else {\n            log::error!(\"Failed to accept connection on callback listener\");\n            let _ = tx.send(Err(Error::IoError(std::io::Error::other(\n                \"Failed to accept connection\",\n            ))));\n        }\n    });\n\n    // 4. Wait for the result with timeout\n    let result = match rx.recv_timeout(timeout) {\n        Ok(r) => r,\n        Err(e) => {\n            log::error!(\"Timed out or channel error: {e}\");\n            return Err(Error::from(e));\n        }\n    };\n\n    // 5. Wait for thread completion\n    if handle.join().is_err() {\n        log::warn!(\"thread join failed, but continuing with result\");\n    }\n\n    // 6. Return the result\n    result\n}\n\n/// Handles an incoming TCP connection for a generic OAuth callback.\nfn handle_callback_connection(\n    stream: &mut TcpStream,\n    tx: &mpsc::Sender<Result<String, Error>>,\n    parameter_name: &'static str,\n) {\n    let mut reader = BufReader::new(&mut *stream);\n    let mut request_line = String::new();\n\n    if reader.read_line(&mut request_line).is_ok() {\n        match extract_parameter_from_request(&request_line, parameter_name) {\n            Some(value) => {\n                log::info!(\"received callback parameter '{parameter_name}'.\");\n                send_success_response(stream);\n                let _ = tx.send(Ok(value));\n            }\n            None => {\n                let err_msg = format!(\n                    \"Failed to extract parameter '{parameter_name}' from request: {request_line}\",\n                );\n                log::error!(\"{err_msg}\");\n                let _ = tx.send(Err(Error::OAuthError(err_msg)));\n            }\n        }\n    } else {\n        log::error!(\"Failed to read request line from callback.\");\n        let _ = tx.send(Err(Error::IoError(std::io::Error::other(\n            \"Failed to read request line\",\n        ))));\n    }\n}\n\n/// Extracts a specified query parameter from an HTTP request line.\nfn extract_parameter_from_request(request_line: &str, parameter_name: &str) -> Option<String> {\n    request_line\n        .split_whitespace()\n        .nth(1)\n        .and_then(|path| Url::parse(&format!(\"http://localhost{path}\")).ok())\n        .and_then(|url| {\n            url.query_pairs()\n                .find(|(key, _)| key == parameter_name)\n                .map(|(_, value)| value.into_owned())\n        })\n}\n\npub fn get_authcode_listener(\n    socket_address: SocketAddr,\n    timeout: Duration,\n) -> Result<AuthorizationCode, Error> {\n    listen_for_callback_parameter(socket_address, timeout, \"code\").map(AuthorizationCode::new)\n}\n\npub fn send_success_response(stream: &mut TcpStream) {\n    let response = \"HTTP/1.1 200 OK\\r\\n\\r\\n\\\n        <html>\\\n        <head>\\\n            <style>\\\n                body {\\\n                    background-color: #121212;\\\n                    color: #ffffff;\\\n                    font-family: sans-serif;\\\n                    display: flex;\\\n                    justify-content: center;\\\n                    align-items: center;\\\n                    height: 100vh;\\\n                    margin: 0;\\\n                }\\\n                a {\\\n                    color: #aaaaaa;\\\n                    text-decoration: underline;\\\n                    cursor: pointer;\\\n                }\\\n            </style>\\\n        </head>\\\n        <body>\\\n            <div>Successfully authenticated! You can close this window now.</div>\\\n        </body>\\\n        </html>\";\n    let _ = stream.write_all(response.as_bytes());\n}\n\nfn create_spotify_oauth_client(redirect_port: u16) -> BasicClient {\n    let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);\n    let redirect_uri = format!(\"http://{redirect_address}/login\");\n\n    BasicClient::new(\n        ClientId::new(crate::session::access_token::CLIENT_ID.to_string()),\n        None,\n        AuthUrl::new(\"https://accounts.spotify.com/authorize\".to_string()).unwrap(),\n        Some(TokenUrl::new(\"https://accounts.spotify.com/api/token\".to_string()).unwrap()),\n    )\n    .set_redirect_uri(RedirectUrl::new(redirect_uri).expect(\"Invalid redirect URL\"))\n}\n\npub fn generate_auth_url(redirect_port: u16) -> (String, PkceCodeVerifier) {\n    let client = create_spotify_oauth_client(redirect_port);\n    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();\n\n    let (auth_url, _) = client\n        .authorize_url(CsrfToken::new_random)\n        .add_scopes(get_scopes())\n        .set_pkce_challenge(pkce_challenge)\n        .url();\n\n    (auth_url.to_string(), pkce_verifier)\n}\n\npub fn exchange_code_for_token(\n    redirect_port: u16,\n    code: AuthorizationCode,\n    pkce_verifier: PkceCodeVerifier,\n) -> String {\n    let client = create_spotify_oauth_client(redirect_port);\n\n    let token_response = client\n        .exchange_code(code)\n        .set_pkce_verifier(pkce_verifier)\n        .request(http_client)\n        .expect(\"Failed to exchange code for token\");\n\n    token_response.access_token().secret().to_string()\n}\n\nfn get_scopes() -> Vec<Scope> {\n    crate::session::access_token::ACCESS_SCOPES\n        .split(',')\n        .map(|s| Scope::new(s.trim().to_string()))\n        .collect()\n}\n"
  },
  {
    "path": "psst-core/src/player/file.rs",
    "content": "use std::{\n    fs, io,\n    io::{Seek, SeekFrom},\n    path::PathBuf,\n    sync::Arc,\n    thread,\n    thread::JoinHandle,\n    time::Duration,\n};\n\nuse symphonia::core::codecs::CodecType;\n\nuse crate::{\n    audio::{\n        decode::{AudioCodecFormat, AudioDecoder},\n        decrypt::{AudioDecrypt, AudioKey},\n        normalize::NormalizationData,\n    },\n    cache::CacheHandle,\n    cdn::{CdnHandle, CdnUrl},\n    error::Error,\n    item_id::{FileId, ItemId},\n    util::OffsetFile,\n};\n\nuse librespot_protocol::metadata::audio_file::Format;\n\nuse super::storage::{StreamRequest, StreamStorage, StreamWriter};\n\n#[derive(Debug, Clone, Copy)]\npub struct MediaPath {\n    pub item_id: ItemId,\n    pub file_id: FileId,\n    pub file_format: AudioFormat,\n    pub duration: Duration,\n}\n\n// possibly should be combined with AudioCodecFormat?\n#[derive(Debug, Clone, Copy)]\npub enum AudioFormat {\n    Mp3,\n    OggVorbis,\n    Unsupported,\n}\n\nimpl AudioFormat {\n    pub fn from_protocol(format: Format) -> Self {\n        use Format::*;\n        match format {\n            MP3_256 | MP3_320 | MP3_160 | MP3_96 | MP3_160_ENC => Self::Mp3,\n            OGG_VORBIS_96 | OGG_VORBIS_160 | OGG_VORBIS_320 => Self::OggVorbis,\n            _ => Self::Unsupported,\n        }\n    }\n\n    pub fn from_codec(codec: CodecType) -> Self {\n        use symphonia::core::codecs::*;\n        if codec == CODEC_TYPE_MP3 {\n            Self::Mp3\n        } else if codec == CODEC_TYPE_VORBIS {\n            Self::OggVorbis\n        } else {\n            Self::Unsupported\n        }\n    }\n}\n\npub enum MediaFile {\n    Streamed {\n        streamed_file: Arc<StreamedFile>,\n        servicing_handle: JoinHandle<()>,\n    },\n    Cached {\n        cached_file: CachedFile,\n    },\n    Local {\n        path: MediaPath,\n    },\n}\n\nimpl MediaFile {\n    pub fn supported_audio_formats_for_bitrate(bitrate: usize) -> &'static [Format] {\n        match bitrate {\n            96 => &[\n                Format::OGG_VORBIS_96,\n                Format::MP3_96,\n                Format::OGG_VORBIS_160,\n                Format::MP3_160,\n                Format::MP3_160_ENC,\n                Format::MP3_256,\n                Format::OGG_VORBIS_320,\n                Format::MP3_320,\n            ],\n            160 => &[\n                Format::OGG_VORBIS_160,\n                Format::MP3_160,\n                Format::MP3_160_ENC,\n                Format::MP3_256,\n                Format::OGG_VORBIS_320,\n                Format::MP3_320,\n                Format::OGG_VORBIS_96,\n                Format::MP3_96,\n            ],\n            320 => &[\n                Format::OGG_VORBIS_320,\n                Format::MP3_320,\n                Format::MP3_256,\n                Format::OGG_VORBIS_160,\n                Format::MP3_160,\n                Format::MP3_160_ENC,\n                Format::OGG_VORBIS_96,\n                Format::MP3_96,\n            ],\n            _ => unreachable!(),\n        }\n    }\n\n    pub fn open(path: MediaPath, cdn: CdnHandle, cache: CacheHandle) -> Result<Self, Error> {\n        let cached_path = cache.audio_file_path(path.file_id);\n        if cached_path.exists() {\n            let cached_file = CachedFile::open(path, cached_path)?;\n            Ok(Self::Cached { cached_file })\n        } else {\n            let streamed_file = Arc::new(StreamedFile::open(path, cdn, cache)?);\n            let servicing_handle = thread::spawn({\n                let streamed_file = Arc::clone(&streamed_file);\n                move || {\n                    streamed_file\n                        .service_streaming()\n                        .expect(\"Streaming thread failed\");\n                }\n            });\n            Ok(Self::Streamed {\n                streamed_file,\n                servicing_handle,\n            })\n        }\n    }\n\n    pub fn local(path: MediaPath) -> Self {\n        Self::Local { path }\n    }\n\n    pub fn path(&self) -> MediaPath {\n        match self {\n            Self::Streamed { streamed_file, .. } => streamed_file.path,\n            Self::Cached { cached_file, .. } => cached_file.path,\n            Self::Local { path } => *path,\n        }\n    }\n\n    pub fn storage(&self) -> Option<&StreamStorage> {\n        match self {\n            Self::Streamed { streamed_file, .. } => Some(&streamed_file.storage),\n            Self::Cached { cached_file, .. } => Some(&cached_file.storage),\n            Self::Local { .. } => None,\n        }\n    }\n\n    pub fn remote_audio_source(\n        &self,\n        key: AudioKey,\n    ) -> Result<(AudioDecoder, NormalizationData), Error> {\n        let reader = self\n            .storage()\n            .expect(\"storage always set for remote files\")\n            .reader()?;\n        let mut decrypted = AudioDecrypt::new(key, reader);\n        let normalization = NormalizationData::parse(&mut decrypted)?;\n        let encoded = OffsetFile::new(decrypted, self.header_length())?;\n        let decoded = AudioDecoder::new(encoded, self.codec_format())?;\n        Ok((decoded, normalization))\n    }\n\n    pub fn local_audio_source(&self) -> Result<(AudioDecoder, NormalizationData), Error> {\n        let mut reader = fs::File::open(self.path().item_id.to_local())?;\n        let normalization = NormalizationData::parse(&mut reader)?;\n        let encoded = OffsetFile::new(reader, self.header_length())?;\n        let decoded = AudioDecoder::new(encoded, self.codec_format())?;\n        Ok((decoded, normalization))\n    }\n\n    fn header_length(&self) -> u64 {\n        match self.path().file_format {\n            AudioFormat::OggVorbis => 167,\n            _ => 0,\n        }\n    }\n\n    fn codec_format(&self) -> AudioCodecFormat {\n        match self.path().file_format {\n            AudioFormat::OggVorbis => AudioCodecFormat::OggVorbis,\n            AudioFormat::Mp3 => AudioCodecFormat::Mp3,\n            AudioFormat::Unsupported => unreachable!(\"unsupported codec\"),\n        }\n    }\n}\n\npub struct StreamedFile {\n    path: MediaPath,\n    storage: StreamStorage,\n    url: CdnUrl,\n    cdn: CdnHandle,\n    cache: CacheHandle,\n}\n\nimpl StreamedFile {\n    fn open(path: MediaPath, cdn: CdnHandle, cache: CacheHandle) -> Result<StreamedFile, Error> {\n        // First, we need to resolve URL of the file contents.\n        let url = cdn.resolve_audio_file_url(path.file_id)?;\n        log::debug!(\"resolved file URL: {:?}\", url.url);\n\n        // How many bytes we request in the first chunk.\n        const INITIAL_REQUEST_LENGTH: u64 = 1024 * 6;\n\n        // Send the initial request, that gives us the total file length and the\n        // beginning of the contents.  Use the total length for creating the backing\n        // data storage.\n        let (total_length, mut initial_data) =\n            cdn.fetch_file_range(&url.url, 0, INITIAL_REQUEST_LENGTH)?;\n        let storage = StreamStorage::new(total_length)?;\n\n        // Pipe the initial data from the request body into storage.\n        io::copy(&mut initial_data, &mut storage.writer()?)?;\n\n        Ok(StreamedFile {\n            path,\n            storage,\n            url,\n            cdn,\n            cache,\n        })\n    }\n\n    fn service_streaming(&self) -> Result<(), Error> {\n        let mut last_url = self.url.clone();\n        let mut fresh_url = || -> Result<CdnUrl, Error> {\n            if last_url.is_expired() {\n                last_url = self.cdn.resolve_audio_file_url(self.path.file_id)?;\n            }\n            Ok(last_url.clone())\n        };\n        let mut download_range = |offset, length| -> Result<(), Error> {\n            let thread_name = format!(\n                \"cdn-{}-{}..{}\",\n                self.path.file_id.to_base16(),\n                offset,\n                offset + length\n            );\n            // TODO: We spawn threads here without any accounting.  Seems wrong.\n            thread::Builder::new().name(thread_name).spawn({\n                let url = fresh_url()?.url;\n                let cdn = self.cdn.clone();\n                let cache = self.cache.clone();\n                let mut writer = self.storage.writer()?;\n                let file_path = self.storage.path().to_path_buf();\n                let file_id = self.path.file_id;\n                move || {\n                    match load_range(&mut writer, &cdn, &url, offset, length) {\n                        Ok(_) => {\n                            // If the file is completely downloaded, copy it to cache.\n                            if writer.is_complete() && !cache.audio_file_path(file_id).exists() {\n                                // TODO: We should do this atomically.\n                                if let Err(err) = cache.save_audio_file(file_id, file_path) {\n                                    log::warn!(\"failed to save audio file to cache: {err:?}\");\n                                }\n                            }\n                        }\n                        Err(err) => {\n                            log::error!(\"failed to download: {err}\");\n                            // Range failed to download, remove it from the requested set.\n                            writer.mark_as_not_requested(offset, length);\n                        }\n                    }\n                }\n            })?;\n\n            Ok(())\n        };\n\n        while let Ok(req) = self.storage.receiver().recv() {\n            match req {\n                StreamRequest::Preload { offset, length } => {\n                    if let Err(err) = download_range(offset, length) {\n                        log::error!(\"failed to request audio range: {err:?}\");\n                    }\n                }\n                StreamRequest::Blocked { offset } => {\n                    log::info!(\"blocked at {offset}\");\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\npub struct CachedFile {\n    path: MediaPath,\n    storage: StreamStorage,\n}\n\nimpl CachedFile {\n    fn open(path: MediaPath, file_path: PathBuf) -> Result<Self, Error> {\n        Ok(Self {\n            path,\n            storage: StreamStorage::from_complete_file(file_path)?,\n        })\n    }\n}\n\nfn load_range(\n    writer: &mut StreamWriter,\n    cdn: &CdnHandle,\n    url: &str,\n    offset: u64,\n    length: u64,\n) -> Result<(), Error> {\n    log::trace!(\"downloading {}..{}\", offset, offset + length);\n\n    // Download range of data from the CDN.  Block until we a have reader of the\n    // request body.\n    let (_total_length, mut reader) = cdn.fetch_file_range(url, offset, length)?;\n\n    // Pipe it into storage. Blocks until fully written, but readers sleeping on\n    // this file should be notified as soon as their offset is covered.\n    writer.seek(SeekFrom::Start(offset))?;\n    io::copy(&mut reader, writer)?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "psst-core/src/player/item.rs",
    "content": "use std::time::Duration;\n\nuse crate::{\n    audio::{\n        decode::AudioDecoder, decrypt::AudioKey, normalize::NormalizationLevel, probe::TrackProbe,\n    },\n    cache::CacheHandle,\n    cdn::CdnHandle,\n    error::Error,\n    item_id::{ItemId, ItemIdType, LocalItemRegistry},\n    metadata::{Fetch, ToMediaPath},\n    session::SessionService,\n};\n\nuse librespot_protocol::metadata::{Episode, Track};\n\nuse super::{\n    file::{AudioFormat, MediaFile, MediaPath},\n    PlaybackConfig,\n};\n\npub struct LoadedPlaybackItem {\n    pub file: MediaFile,\n    pub source: AudioDecoder,\n    pub norm_factor: f32,\n}\n\n#[derive(Debug, Clone, Copy, Eq, PartialEq)]\npub struct PlaybackItem {\n    pub item_id: ItemId,\n    pub norm_level: NormalizationLevel,\n}\n\nimpl PlaybackItem {\n    pub fn load(\n        &self,\n        session: &SessionService,\n        cdn: CdnHandle,\n        cache: CacheHandle,\n        config: &PlaybackConfig,\n    ) -> Result<LoadedPlaybackItem, Error> {\n        let path = load_media_path(self.item_id, session, &cache, config)?;\n        let (file, source, norm_data) = match self.item_id.id_type {\n            ItemIdType::LocalFile => {\n                let file = MediaFile::local(path);\n                let (source, norm_data) = file.local_audio_source()?;\n                (file, source, norm_data)\n            }\n            _ => {\n                let key = load_audio_key(&path, session, &cache)?;\n                let file = MediaFile::open(path, cdn, cache)?;\n                let (source, norm_data) = file.remote_audio_source(key)?;\n                (file, source, norm_data)\n            }\n        };\n        let norm_factor = norm_data.factor_for_level(self.norm_level, config.pregain);\n        Ok(LoadedPlaybackItem {\n            file,\n            source,\n            norm_factor,\n        })\n    }\n}\n\nfn load_media_path(\n    item_id: ItemId,\n    session: &SessionService,\n    cache: &CacheHandle,\n    config: &PlaybackConfig,\n) -> Result<MediaPath, Error> {\n    match item_id.id_type {\n        ItemIdType::Track => {\n            load_media_path_from_track_or_alternative(item_id, session, cache, config)\n        }\n        ItemIdType::Podcast => load_media_path_from_episode(item_id, session, cache, config),\n        ItemIdType::LocalFile => load_media_path_from_local(item_id),\n        ItemIdType::Unknown => unimplemented!(),\n    }\n}\n\nfn load_media_path_from_track_or_alternative(\n    item_id: ItemId,\n    session: &SessionService,\n    cache: &CacheHandle,\n    config: &PlaybackConfig,\n) -> Result<MediaPath, Error> {\n    let track = load_track(item_id, session, cache)?;\n    let country = get_country_code(session, cache);\n    let path = match country {\n        Some(user_country) if track.is_restricted_in_region(&user_country) => {\n            // The track is regionally restricted and is unavailable.  Let's try to find an\n            // alternative track.\n            let alt_id = track\n                .find_allowed_alternative(&user_country)\n                .ok_or(Error::MediaFileNotFound)?;\n            let alt_track = load_track(alt_id, session, cache)?;\n            let alt_path = alt_track\n                .to_media_path(config.bitrate)\n                .ok_or(Error::MediaFileNotFound)?;\n            // We've found an alternative track with a fitting audio file.  Let's cheat a\n            // little and pretend we've obtained it from the requested track.\n            // TODO: We should be honest and display the real track information.\n            MediaPath {\n                item_id,\n                ..alt_path\n            }\n        }\n        _ => {\n            // Either we do not have a country code loaded or the track is available, return\n            // it.\n            track\n                .to_media_path(config.bitrate)\n                .ok_or(Error::MediaFileNotFound)?\n        }\n    };\n    Ok(path)\n}\n\nfn load_media_path_from_episode(\n    item_id: ItemId,\n    session: &SessionService,\n    cache: &CacheHandle,\n    config: &PlaybackConfig,\n) -> Result<MediaPath, Error> {\n    let episode = load_episode(item_id, session, cache)?;\n    let country = get_country_code(session, cache);\n    let path = match country {\n        Some(user_country) if episode.is_restricted_in_region(&user_country) => {\n            // Episode is restricted, and doesn't have any alternatives.\n            return Err(Error::MediaFileNotFound);\n        }\n        _ => episode\n            .to_media_path(config.bitrate)\n            .ok_or(Error::MediaFileNotFound)?,\n    };\n    Ok(path)\n}\n\nfn load_media_path_from_local(item_id: ItemId) -> Result<MediaPath, Error> {\n    let path = LocalItemRegistry::get(item_id.id).expect(\"valid local item ID\");\n    let probe = TrackProbe::new(&path)?;\n    Ok(MediaPath {\n        item_id,\n        file_id: Default::default(),\n        file_format: AudioFormat::from_codec(probe.codec),\n        // It's possible (though unlikely) that we're unable to determine the track\n        // duration from the codec params; in that case, default to 0 and let it\n        // be calculated at runtime as we play the track.\n        duration: probe.duration.unwrap_or(Duration::from_millis(0)),\n    })\n}\n\nfn get_country_code(session: &SessionService, cache: &CacheHandle) -> Option<String> {\n    if let Some(cached_country_code) = cache.get_country_code() {\n        Some(cached_country_code)\n    } else {\n        let country_code = session.connected().ok()?.get_country_code()?;\n        if let Err(err) = cache.save_country_code(&country_code) {\n            log::warn!(\"failed to save country code to cache: {err:?}\");\n        }\n        Some(country_code)\n    }\n}\n\nfn load_track(\n    item_id: ItemId,\n    session: &SessionService,\n    cache: &CacheHandle,\n) -> Result<Track, Error> {\n    if let Some(cached_track) = cache.get_track(item_id) {\n        Ok(cached_track)\n    } else {\n        let track = Track::fetch(session, item_id)?;\n        if let Err(err) = cache.save_track(item_id, &track) {\n            log::warn!(\"failed to save track to cache: {err:?}\");\n        }\n        Ok(track)\n    }\n}\n\nfn load_episode(\n    item_id: ItemId,\n    session: &SessionService,\n    cache: &CacheHandle,\n) -> Result<Episode, Error> {\n    if let Some(cached_episode) = cache.get_episode(item_id) {\n        Ok(cached_episode)\n    } else {\n        let episode = Episode::fetch(session, item_id)?;\n        if let Err(err) = cache.save_episode(item_id, &episode) {\n            log::warn!(\"failed to save episode to cache: {err:?}\");\n        }\n        Ok(episode)\n    }\n}\n\nfn load_audio_key(\n    path: &MediaPath,\n    session: &SessionService,\n    cache: &CacheHandle,\n) -> Result<AudioKey, Error> {\n    if let Some(cached_key) = cache.get_audio_key(path.item_id, path.file_id) {\n        Ok(cached_key)\n    } else {\n        let key = session\n            .connected()?\n            .get_audio_key(path.item_id, path.file_id)?;\n        if let Err(err) = cache.save_audio_key(path.item_id, path.file_id, &key) {\n            log::warn!(\"failed to save audio key to cache: {err:?}\");\n        }\n        Ok(key)\n    }\n}\n"
  },
  {
    "path": "psst-core/src/player/mod.rs",
    "content": "pub mod file;\npub mod item;\npub mod queue;\nmod storage;\nmod worker;\n\nuse std::{mem, thread, thread::JoinHandle, time::Duration};\n\nuse crossbeam_channel::{unbounded, Receiver, Sender};\n\nuse crate::{\n    audio::output::{AudioOutput, AudioSink, DefaultAudioOutput, DefaultAudioSink},\n    cache::CacheHandle,\n    cdn::CdnHandle,\n    error::Error,\n    session::SessionService,\n};\n\nuse self::{\n    file::MediaPath,\n    item::{LoadedPlaybackItem, PlaybackItem},\n    queue::{Queue, QueueBehavior},\n    worker::PlaybackManager,\n};\n\nconst PREVIOUS_TRACK_THRESHOLD: Duration = Duration::from_secs(3);\nconst STOP_AFTER_CONSECUTIVE_LOADING_FAILURES: usize = 3;\n\n#[derive(Clone)]\npub struct PlaybackConfig {\n    pub bitrate: usize,\n    pub pregain: f32,\n}\n\nimpl Default for PlaybackConfig {\n    fn default() -> Self {\n        Self {\n            bitrate: 320,\n            pregain: 3.0,\n        }\n    }\n}\n\npub struct Player {\n    state: PlayerState,\n    preload: PreloadState,\n    session: SessionService,\n    cdn: CdnHandle,\n    cache: CacheHandle,\n    config: PlaybackConfig,\n    queue: Queue,\n    sender: Sender<PlayerEvent>,\n    receiver: Receiver<PlayerEvent>,\n    audio_output_sink: DefaultAudioSink,\n    playback_mgr: PlaybackManager,\n    consecutive_loading_failures: usize,\n}\n\nimpl Player {\n    pub fn new(\n        session: SessionService,\n        cdn: CdnHandle,\n        cache: CacheHandle,\n        config: PlaybackConfig,\n        audio_output: &DefaultAudioOutput,\n    ) -> Self {\n        let (sender, receiver) = unbounded();\n        Self {\n            playback_mgr: PlaybackManager::new(audio_output.sink(), sender.clone()),\n            session,\n            cdn,\n            cache,\n            config,\n            sender,\n            receiver,\n            audio_output_sink: audio_output.sink(),\n            state: PlayerState::Stopped,\n            preload: PreloadState::None,\n            queue: Queue::new(),\n            consecutive_loading_failures: 0,\n        }\n    }\n\n    pub fn sender(&self) -> Sender<PlayerEvent> {\n        self.sender.clone()\n    }\n\n    pub fn receiver(&self) -> Receiver<PlayerEvent> {\n        self.receiver.clone()\n    }\n\n    pub fn handle(&mut self, event: PlayerEvent) {\n        match event {\n            PlayerEvent::Command(cmd) => self.handle_command(cmd),\n            PlayerEvent::Loaded { item, result } => self.handle_loaded(item, result),\n            PlayerEvent::Preloaded { item, result } => self.handle_preloaded(item, result),\n            PlayerEvent::Position { position, path } => self.handle_position(position, path),\n            PlayerEvent::EndOfTrack => self.handle_end_of_track(),\n            PlayerEvent::Loading { .. }\n            | PlayerEvent::Playing { .. }\n            | PlayerEvent::Pausing { .. }\n            | PlayerEvent::Resuming { .. }\n            | PlayerEvent::Stopped\n            | PlayerEvent::Blocked { .. } => {}\n        };\n    }\n\n    fn handle_command(&mut self, cmd: PlayerCommand) {\n        match cmd {\n            PlayerCommand::LoadQueue { items, position } => self.load_queue(items, position),\n            PlayerCommand::LoadAndPlay { item } => self.load_and_play(item),\n            PlayerCommand::Preload { item } => self.preload(item),\n            PlayerCommand::Pause => self.pause(),\n            PlayerCommand::Resume => self.resume(),\n            PlayerCommand::PauseOrResume => self.pause_or_resume(),\n            PlayerCommand::Previous => self.previous(),\n            PlayerCommand::Next => self.next(),\n            PlayerCommand::Stop => self.stop(),\n            PlayerCommand::Seek { position } => self.seek(position),\n            PlayerCommand::Configure { config } => self.configure(config),\n            PlayerCommand::SetQueueBehavior { behavior } => self.queue.set_behaviour(behavior),\n            PlayerCommand::AddToQueue { item } => self.queue.add(item),\n            PlayerCommand::SetVolume { volume } => self.set_volume(volume),\n        }\n    }\n\n    fn handle_loaded(&mut self, item: PlaybackItem, result: Result<LoadedPlaybackItem, Error>) {\n        match self.state {\n            PlayerState::Loading {\n                item: requested_item,\n                ..\n            } if item == requested_item => match result {\n                Ok(loaded_item) => {\n                    self.consecutive_loading_failures = 0;\n                    self.play_loaded(loaded_item);\n                }\n                Err(err) => {\n                    self.consecutive_loading_failures += 1;\n                    if self.consecutive_loading_failures < STOP_AFTER_CONSECUTIVE_LOADING_FAILURES {\n                        log::error!(\"skipping, error while loading: {err}\");\n                        self.next();\n                    } else {\n                        log::error!(\"stopping, error while loading: {err}\");\n                        self.stop();\n                    }\n                }\n            },\n            _ => {\n                log::info!(\"stale load result received, ignoring\");\n            }\n        }\n    }\n\n    fn handle_preloaded(&mut self, item: PlaybackItem, result: Result<LoadedPlaybackItem, Error>) {\n        match self.preload {\n            PreloadState::Preloading {\n                item: requested_item,\n                ..\n            } if item == requested_item => match result {\n                Ok(loaded_item) => {\n                    log::info!(\"preloaded audio file\");\n                    self.preload = PreloadState::Preloaded { item, loaded_item };\n                }\n                Err(err) => {\n                    log::error!(\"failed to preload audio file, error while opening: {err}\");\n                    self.preload = PreloadState::None;\n                }\n            },\n            _ => {\n                log::info!(\"stale preload result received, ignoring\");\n\n                // We are not preloading this item, but because we sometimes extract the\n                // preloading thread and use it for loading, let's check if the item is not\n                // being loaded now.\n                self.handle_loaded(item, result);\n            }\n        }\n    }\n\n    fn handle_position(&mut self, new_position: Duration, path: MediaPath) {\n        match &mut self.state {\n            PlayerState::Playing { position, .. } | PlayerState::Paused { position, .. } => {\n                *position = new_position;\n            }\n            _ => {\n                log::warn!(\"received unexpected position report\");\n            }\n        }\n        const PRELOAD_BEFORE_END_OF_TRACK: Duration = Duration::from_secs(30);\n        let time_until_end_of_track = path.duration.checked_sub(new_position).unwrap_or_default();\n        if time_until_end_of_track <= PRELOAD_BEFORE_END_OF_TRACK {\n            if let Some(&item_to_preload) = self.queue.get_following() {\n                self.preload(item_to_preload);\n            }\n        }\n    }\n\n    fn handle_end_of_track(&mut self) {\n        self.queue.skip_to_following();\n        if let Some(&item) = self.queue.get_current() {\n            self.load_and_play(item);\n        } else {\n            self.stop();\n        }\n    }\n\n    fn load_queue(&mut self, items: Vec<PlaybackItem>, position: usize) {\n        self.queue.fill(items, position);\n        if let Some(&item) = self.queue.get_current() {\n            self.load_and_play(item);\n        } else {\n            self.stop();\n        }\n    }\n\n    fn load_and_play(&mut self, item: PlaybackItem) {\n        // Make sure to stop the sink, so any current audio source is cleared and the\n        // playback stopped.\n        self.audio_output_sink.stop();\n\n        // Check if the item is already in the preloader state.\n        let loading_handle = match mem::replace(&mut self.preload, PreloadState::None) {\n            PreloadState::Preloaded {\n                item: preloaded_item,\n                loaded_item,\n            } if preloaded_item == item => {\n                // This item is already loaded in the preloader state.\n                self.play_loaded(loaded_item);\n                return;\n            }\n\n            PreloadState::Preloading {\n                item: preloaded_item,\n                loading_handle,\n            } if preloaded_item == item => {\n                // This item is being preloaded. Take it out of the preloader state.\n                loading_handle\n            }\n\n            preloading_other_file_or_none => {\n                self.preload = preloading_other_file_or_none;\n                // Item is not preloaded yet, load it in a background thread.\n                thread::spawn({\n                    let sender = self.sender.clone();\n                    let session = self.session.clone();\n                    let cdn = self.cdn.clone();\n                    let cache = self.cache.clone();\n                    let config = self.config.clone();\n                    move || {\n                        let result = item.load(&session, cdn, cache, &config);\n                        sender.send(PlayerEvent::Loaded { item, result }).unwrap();\n                    }\n                })\n            }\n        };\n\n        self.sender.send(PlayerEvent::Loading { item }).unwrap();\n        self.state = PlayerState::Loading {\n            item,\n            _loading_handle: loading_handle,\n        };\n    }\n\n    fn preload(&mut self, item: PlaybackItem) {\n        if self.is_in_preload(item) {\n            return;\n        }\n        let loading_handle = thread::spawn({\n            let sender = self.sender.clone();\n            let session = self.session.clone();\n            let cdn = self.cdn.clone();\n            let cache = self.cache.clone();\n            let config = self.config.clone();\n            move || {\n                let result = item.load(&session, cdn, cache, &config);\n                sender\n                    .send(PlayerEvent::Preloaded { item, result })\n                    .unwrap();\n            }\n        });\n        self.preload = PreloadState::Preloading {\n            item,\n            loading_handle,\n        };\n    }\n\n    fn set_volume(&mut self, volume: f64) {\n        self.audio_output_sink.set_volume(volume as f32);\n    }\n\n    fn play_loaded(&mut self, loaded_item: LoadedPlaybackItem) {\n        log::info!(\"starting playback\");\n        let path = loaded_item.file.path();\n        let position = Duration::default();\n        self.playback_mgr.play(loaded_item);\n        self.state = PlayerState::Playing { path, position };\n        self.sender\n            .send(PlayerEvent::Playing { path, position })\n            .unwrap();\n    }\n\n    fn pause(&mut self) {\n        match mem::replace(&mut self.state, PlayerState::Invalid) {\n            PlayerState::Playing { path, position } | PlayerState::Paused { path, position } => {\n                log::info!(\"pausing playback\");\n                self.audio_output_sink.pause();\n                self.sender\n                    .send(PlayerEvent::Pausing { path, position })\n                    .unwrap();\n                self.state = PlayerState::Paused { path, position };\n            }\n            _ => {\n                log::warn!(\"invalid state transition\");\n            }\n        }\n    }\n\n    fn resume(&mut self) {\n        match mem::replace(&mut self.state, PlayerState::Invalid) {\n            PlayerState::Playing { path, position } | PlayerState::Paused { path, position } => {\n                log::info!(\"resuming playback\");\n                self.audio_output_sink.resume();\n                self.sender\n                    .send(PlayerEvent::Resuming { path, position })\n                    .unwrap();\n                self.state = PlayerState::Playing { path, position };\n            }\n            _ => {\n                log::warn!(\"invalid state transition\");\n            }\n        }\n    }\n\n    fn pause_or_resume(&mut self) {\n        match &self.state {\n            PlayerState::Playing { .. } => self.pause(),\n            PlayerState::Paused { .. } => self.resume(),\n            _ => {\n                // Do nothing.\n            }\n        }\n    }\n\n    fn previous(&mut self) {\n        if self.is_near_playback_start() {\n            self.queue.skip_to_previous();\n            if let Some(&item) = self.queue.get_current() {\n                self.load_and_play(item);\n            } else {\n                self.stop();\n            }\n        } else {\n            self.seek(Duration::default());\n        }\n    }\n\n    fn next(&mut self) {\n        self.queue.skip_to_next();\n        if let Some(&item) = self.queue.get_current() {\n            self.load_and_play(item);\n        } else {\n            self.stop();\n        }\n    }\n\n    fn stop(&mut self) {\n        self.sender.send(PlayerEvent::Stopped).unwrap();\n        self.audio_output_sink.stop();\n        self.state = PlayerState::Stopped;\n        self.queue.clear();\n        self.consecutive_loading_failures = 0;\n    }\n\n    fn seek(&mut self, position: Duration) {\n        self.playback_mgr.seek(position);\n    }\n\n    fn configure(&mut self, config: PlaybackConfig) {\n        self.config = config;\n    }\n\n    fn is_near_playback_start(&self) -> bool {\n        match self.state {\n            PlayerState::Playing { position, .. } | PlayerState::Paused { position, .. } => {\n                position < PREVIOUS_TRACK_THRESHOLD\n            }\n            _ => false,\n        }\n    }\n\n    fn is_in_preload(&self, item: PlaybackItem) -> bool {\n        match self.preload {\n            PreloadState::Preloading { item: p_item, .. }\n            | PreloadState::Preloaded { item: p_item, .. } => p_item == item,\n            _ => false,\n        }\n    }\n}\n\npub enum PlayerCommand {\n    LoadQueue {\n        items: Vec<PlaybackItem>,\n        position: usize,\n    },\n    LoadAndPlay {\n        item: PlaybackItem,\n    },\n    Preload {\n        item: PlaybackItem,\n    },\n    Pause,\n    Resume,\n    PauseOrResume,\n    Previous,\n    Next,\n    Stop,\n    Seek {\n        position: Duration,\n    },\n    Configure {\n        config: PlaybackConfig,\n    },\n    SetQueueBehavior {\n        behavior: QueueBehavior,\n    },\n    AddToQueue {\n        item: PlaybackItem,\n    },\n    /// Change playback volume to a value in 0.0..=1.0 range.\n    SetVolume {\n        volume: f64,\n    },\n}\n\npub enum PlayerEvent {\n    Command(PlayerCommand),\n    /// Track has started loading.  `Loaded` follows.\n    Loading {\n        item: PlaybackItem,\n    },\n    /// Track loading either succeeded or failed.  `Playing` follows in case of\n    /// success.\n    Loaded {\n        item: PlaybackItem,\n        result: Result<LoadedPlaybackItem, Error>,\n    },\n    /// Next item in queue has been either successfully preloaded or failed to\n    /// preload.\n    Preloaded {\n        item: PlaybackItem,\n        result: Result<LoadedPlaybackItem, Error>,\n    },\n    /// Player has started playing new track.  `Position` events will follow.\n    Playing {\n        path: MediaPath,\n        position: Duration,\n    },\n    /// Player is in a paused state.  `Resuming` might follow.\n    Pausing {\n        path: MediaPath,\n        position: Duration,\n    },\n    /// Player is resuming playback of a track.  `Position` events will follow.\n    Resuming {\n        path: MediaPath,\n        position: Duration,\n    },\n    /// Position of the playback head has changed.\n    Position {\n        path: MediaPath,\n        position: Duration,\n    },\n    /// Player would like to continue playing, but is blocked, waiting for I/O.\n    Blocked {\n        path: MediaPath,\n        position: Duration,\n    },\n    /// Player has finished playing a track.  `Loading` or `Playing` might\n    /// follow if the queue is not empty, `Stopped` will follow if it is.\n    EndOfTrack,\n    /// The queue is empty.\n    Stopped,\n}\n\nenum PlayerState {\n    Loading {\n        item: PlaybackItem,\n        _loading_handle: JoinHandle<()>,\n    },\n    Playing {\n        path: MediaPath,\n        position: Duration,\n    },\n    Paused {\n        path: MediaPath,\n        position: Duration,\n    },\n    Stopped,\n    Invalid,\n}\n\nenum PreloadState {\n    Preloading {\n        item: PlaybackItem,\n        loading_handle: JoinHandle<()>,\n    },\n    Preloaded {\n        item: PlaybackItem,\n        loaded_item: LoadedPlaybackItem,\n    },\n    None,\n}\n"
  },
  {
    "path": "psst-core/src/player/queue.rs",
    "content": "use rand::prelude::SliceRandom;\n\nuse super::PlaybackItem;\n\n#[derive(Default, Debug)]\npub enum QueueBehavior {\n    #[default]\n    Sequential,\n    Random,\n    LoopTrack,\n    LoopAll,\n}\n\npub struct Queue {\n    items: Vec<PlaybackItem>,\n    user_items: Vec<PlaybackItem>,\n    position: usize,\n    user_items_position: usize,\n    positions: Vec<usize>,\n    behavior: QueueBehavior,\n}\n\nimpl Queue {\n    pub fn new() -> Self {\n        Self {\n            items: Vec::new(),\n            user_items: Vec::new(),\n            position: 0,\n            user_items_position: 0,\n            positions: Vec::new(),\n            behavior: QueueBehavior::default(),\n        }\n    }\n\n    pub fn clear(&mut self) {\n        self.items.clear();\n        self.positions.clear();\n        self.position = 0;\n    }\n\n    pub fn fill(&mut self, items: Vec<PlaybackItem>, position: usize) {\n        self.positions.clear();\n        self.items = items;\n        self.position = position;\n        self.compute_positions();\n    }\n\n    pub fn add(&mut self, item: PlaybackItem) {\n        self.user_items.push(item);\n    }\n\n    fn handle_added_queue(&mut self) {\n        if self.user_items.len() > self.user_items_position {\n            self.items.insert(\n                self.positions.len(),\n                self.user_items[self.user_items_position],\n            );\n            self.positions\n                .insert(self.position + 1, self.positions.len());\n            self.user_items_position += 1;\n        }\n    }\n\n    pub fn set_behaviour(&mut self, behavior: QueueBehavior) {\n        self.behavior = behavior;\n        self.compute_positions();\n    }\n\n    fn compute_positions(&mut self) {\n        // In the case of switching away from shuffle, the position should be set back to\n        // where it appears in the actual playlist order.\n        let playlist_position = if self.positions.len() > 1 {\n            self.positions[self.position]\n        } else {\n            self.position\n        };\n        // Start with an ordered 1:1 mapping.\n        self.positions = (0..self.items.len()).collect();\n\n        if let QueueBehavior::Random = self.behavior {\n            // Swap the current position with the first item, so we will start from the\n            // beginning, with the full queue ahead of us.  Then shuffle the rest of the\n            // items and set the position to 0.\n            if self.positions.len() > 1 {\n                self.positions.swap(0, self.position);\n                self.positions[1..].shuffle(&mut rand::rng());\n            }\n            self.position = 0;\n        } else {\n            self.position = playlist_position;\n        }\n    }\n\n    pub fn skip_to_previous(&mut self) {\n        self.position = self.previous_position();\n    }\n\n    pub fn skip_to_next(&mut self) {\n        self.handle_added_queue();\n        self.position = self.next_position();\n    }\n\n    pub fn skip_to_following(&mut self) {\n        self.handle_added_queue();\n        self.position = self.following_position();\n    }\n\n    pub fn get_current(&self) -> Option<&PlaybackItem> {\n        let position = self.positions.get(self.position).copied()?;\n        self.items.get(position)\n    }\n\n    pub fn get_following(&self) -> Option<&PlaybackItem> {\n        if let Some(position) = self.positions.get(self.position).copied() {\n            if let Some(item) = self.items.get(position) {\n                return Some(item);\n            }\n        } else {\n            return self.user_items.first();\n        }\n        None\n    }\n\n    fn previous_position(&self) -> usize {\n        match self.behavior {\n            QueueBehavior::Sequential\n            | QueueBehavior::Random\n            | QueueBehavior::LoopTrack\n            | QueueBehavior::LoopAll => self.position.saturating_sub(1),\n        }\n    }\n\n    fn next_position(&self) -> usize {\n        match self.behavior {\n            QueueBehavior::Sequential | QueueBehavior::Random | QueueBehavior::LoopTrack => {\n                self.position + 1\n            }\n            QueueBehavior::LoopAll => (self.position + 1) % self.items.len(),\n        }\n    }\n\n    fn following_position(&self) -> usize {\n        match self.behavior {\n            QueueBehavior::Sequential | QueueBehavior::Random => self.position + 1,\n            QueueBehavior::LoopTrack => self.position,\n            QueueBehavior::LoopAll => (self.position + 1) % self.items.len(),\n        }\n    }\n}\n"
  },
  {
    "path": "psst-core/src/player/storage.rs",
    "content": "use std::{\n    fs::File,\n    io,\n    io::{Read, Seek, SeekFrom, Write},\n    ops::Range,\n    path::{Path, PathBuf},\n    sync::Arc,\n};\n\nuse crossbeam_channel::{unbounded, Receiver, Sender};\nuse parking_lot::{Condvar, Mutex};\nuse rangemap::RangeSet;\nuse tempfile::NamedTempFile;\n\npub enum StreamRequest {\n    Preload { offset: u64, length: u64 },\n    Blocked { offset: u64 },\n}\n\npub struct StreamStorage {\n    file: StreamFile,\n    data_map: Arc<StreamDataMap>,\n    req_receiver: Receiver<StreamRequest>,\n    req_sender: Sender<StreamRequest>,\n}\n\npub struct StreamReader {\n    reader: File,\n    data_map: Arc<StreamDataMap>,\n    req_sender: Sender<StreamRequest>,\n}\n\npub struct StreamWriter {\n    writer: File,\n    data_map: Arc<StreamDataMap>,\n}\n\nimpl StreamStorage {\n    pub fn new(total_size: u64) -> io::Result<StreamStorage> {\n        // Use a temporary file for the backing storage, stretched to the full size, so\n        // we can seek freely.\n        let tmp_file = NamedTempFile::new()?;\n        tmp_file.as_file().set_len(total_size)?;\n\n        // Create a channel for requesting downloads of data.\n        let (data_req_sender, data_req_receiver) = unbounded();\n\n        Ok(StreamStorage {\n            file: StreamFile::Temporary(tmp_file),\n            req_receiver: data_req_receiver,\n            req_sender: data_req_sender,\n            data_map: Arc::new(StreamDataMap {\n                total_size,\n                downloaded: Mutex::new(RangeSet::new()),\n                requested: Mutex::new(RangeSet::new()),\n                condvar: Condvar::new(),\n            }),\n        })\n    }\n\n    pub fn from_complete_file(path: PathBuf) -> io::Result<StreamStorage> {\n        // Query for the total file size.\n        let total_size = path.metadata()?.len();\n\n        // Create the data channel even though it will not be used, as the file should\n        // be complete.  We could also turn these into `Option`s.\n        let (data_req_sender, data_req_receiver) = unbounded();\n\n        // Because the file is complete, let's mark the full range of data as\n        // downloaded.  We mark it as requested as well, because the downloaded set is\n        // always ⊆ the requested set.\n        let mut downloaded_set = RangeSet::new();\n        downloaded_set.insert(0..total_size);\n        let requested_set = downloaded_set.clone();\n\n        Ok(StreamStorage {\n            file: StreamFile::Persisted(path),\n            req_receiver: data_req_receiver,\n            req_sender: data_req_sender,\n            data_map: Arc::new(StreamDataMap {\n                total_size,\n                downloaded: Mutex::new(downloaded_set),\n                requested: Mutex::new(requested_set),\n                condvar: Condvar::new(),\n            }),\n        })\n    }\n\n    pub fn reader(&self) -> io::Result<StreamReader> {\n        Ok(StreamReader {\n            reader: self.file.reopen()?, // Re-opened files have a starting seek position.\n            data_map: self.data_map.clone(),\n            req_sender: self.req_sender.clone(),\n        })\n    }\n\n    pub fn writer(&self) -> io::Result<StreamWriter> {\n        Ok(StreamWriter {\n            writer: self.file.reopen()?, // Re-opened files have a starting seek position.\n            data_map: self.data_map.clone(),\n        })\n    }\n\n    pub fn receiver(&self) -> &Receiver<StreamRequest> {\n        &self.req_receiver\n    }\n\n    pub fn path(&self) -> &Path {\n        self.file.path()\n    }\n}\n\nenum StreamFile {\n    Temporary(NamedTempFile),\n    Persisted(PathBuf),\n}\n\nimpl StreamFile {\n    fn reopen(&self) -> io::Result<File> {\n        match self {\n            StreamFile::Temporary(tmp_file) => tmp_file.reopen(),\n            StreamFile::Persisted(path) => File::open(path),\n        }\n    }\n\n    fn path(&self) -> &Path {\n        match self {\n            StreamFile::Temporary(tmp_file) => tmp_file.path(),\n            StreamFile::Persisted(path) => path,\n        }\n    }\n}\n\nimpl StreamWriter {\n    pub fn is_complete(&self) -> bool {\n        self.data_map.is_complete()\n    }\n\n    pub fn mark_as_not_requested(&self, offset: u64, length: u64) {\n        self.data_map.mark_as_not_requested(offset, length);\n    }\n}\n\nimpl Write for StreamWriter {\n    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {\n        let position = self.writer.stream_position()?;\n        let written = self.writer.write(buf)?;\n        self.data_map.mark_as_downloaded(position, written as u64);\n        Ok(written)\n    }\n\n    fn flush(&mut self) -> io::Result<()> {\n        self.writer.flush()\n    }\n}\n\nimpl Seek for StreamWriter {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        self.writer.seek(pos)\n    }\n}\n\nconst MINIMUM_READ_LENGTH: u64 = 1024 * 64;\nconst PREFETCH_READ_LENGTH: u64 = 1024 * 256;\n\nimpl Read for StreamReader {\n    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n        let position = self.reader.stream_position()?;\n        let remaining_len = self.data_map.remaining(position);\n        if remaining_len == 0 {\n            return Ok(0); // We're at the end of the file.\n        }\n        let needed_len = remaining_len.min(buf.len() as u64);\n\n        // Make sure that at least `PREFETCH_READ_LENGTH` bytes in front of the reading\n        // head is requested.\n        let prefetch_len = needed_len.max(PREFETCH_READ_LENGTH).min(remaining_len);\n        for (pos, len) in self.data_map.not_yet_requested(position, prefetch_len) {\n            let req_len = len.max(MINIMUM_READ_LENGTH);\n            self.data_map.mark_as_requested(pos, req_len);\n            self.req_sender\n                .send(StreamRequest::Preload {\n                    offset: pos,\n                    length: req_len,\n                })\n                .expect(\"Data request channel was closed\");\n        }\n\n        // Block and wait until at least a part of the range is available, and read it.\n        let ready_to_read_len = self.data_map.wait_for(position, |offset| {\n            // Notify the servicing thread we are blocked, so it can possibly prioritize the\n            // blocked offset.\n            self.req_sender\n                .send(StreamRequest::Blocked { offset })\n                .expect(\"Data request channel was closed\");\n        });\n        assert!(ready_to_read_len > 0);\n        self.reader\n            .read(&mut buf[..ready_to_read_len.min(needed_len) as usize])\n    }\n}\n\nimpl Seek for StreamReader {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        self.reader.seek(pos)\n    }\n}\n\n#[derive(Debug)]\nstruct StreamDataMap {\n    total_size: u64,\n    // Contains ranges of data requested from the server.  Downloaded ranges are not removed from\n    // this set.\n    requested: Mutex<RangeSet<u64>>,\n    // Contains ranges of data sure to be present in the backing storage.  Always a subset of the\n    // requested ranges.\n    downloaded: Mutex<RangeSet<u64>>,\n    condvar: Condvar,\n}\n\nimpl StreamDataMap {\n    /// Return the number of bytes from offset until the end of the file.\n    fn remaining(&self, offset: u64) -> u64 {\n        self.total_size.saturating_sub(offset)\n    }\n\n    /// Return a vector of sub-ranges of `offset..offset+length` that have not\n    /// yet been requested from the backend.\n    fn not_yet_requested(&self, offset: u64, length: u64) -> Vec<(u64, u64)> {\n        self.requested\n            .lock()\n            .gaps(&(offset..offset + length))\n            .map(|r| range_to_offset_and_length(&r))\n            .collect()\n    }\n\n    /// Mark given range as requested from the backend, so we can avoid\n    /// requesting it more than once.\n    fn mark_as_requested(&self, offset: u64, length: u64) {\n        self.requested.lock().insert(offset..offset + length);\n    }\n\n    /// Remove range previously marked as requested.\n    fn mark_as_not_requested(&self, offset: u64, length: u64) {\n        self.requested.lock().remove(offset..offset + length);\n    }\n\n    /// Mark the range as downloaded and notify the `self.condvar`, so tasks\n    /// currently blocked in `self.wait_for` are woken up.\n    fn mark_as_downloaded(&self, offset: u64, length: u64) {\n        self.downloaded.lock().insert(offset..offset + length);\n        self.condvar.notify_all();\n    }\n\n    /// Block, waiting until at least some data at given offset is downloaded.\n    /// Returns length that is available.  See `self.mark_as_downloaded`.\n    fn wait_for(&self, offset: u64, blocking_callback: impl Fn(u64)) -> u64 {\n        let mut downloaded = self.downloaded.lock();\n        let mut called_callback = false;\n        loop {\n            if let Some(range) = downloaded.get(&offset) {\n                let (over_ofs, over_len) = range_to_offset_and_length(range);\n                let offset_from_overlapping = offset - over_ofs;\n                let available_len = over_len - offset_from_overlapping;\n                // There is `available_len` bytes of data downloaded, stop waiting.\n                break available_len;\n            } else {\n                // Call the blocking callback, but only the first time we are waiting.\n                if !called_callback {\n                    called_callback = true;\n                    blocking_callback(offset);\n                }\n                // There are no overlaps, wait.\n                self.condvar.wait(&mut downloaded);\n            }\n        }\n    }\n\n    // Returns true if data is completely downloaded.\n    fn is_complete(&self) -> bool {\n        self.downloaded\n            .lock()\n            .gaps(&(0..self.total_size))\n            .next()\n            .is_none()\n    }\n}\n\nfn range_to_offset_and_length(range: &Range<u64>) -> (u64, u64) {\n    (range.start, range.end - range.start)\n}\n"
  },
  {
    "path": "psst-core/src/player/worker.rs",
    "content": "use std::{\n    ops::Range,\n    sync::{\n        atomic::{AtomicU64, Ordering},\n        Arc,\n    },\n    time::Duration,\n};\n\nuse crossbeam_channel::Sender;\nuse rb::{Consumer, Producer, RbConsumer, RbProducer, SpscRb, RB};\nuse symphonia::core::{\n    audio::{SampleBuffer, SignalSpec},\n    units::TimeBase,\n};\n\nuse crate::{\n    actor::{Act, Actor, ActorHandle},\n    audio::{\n        decode::AudioDecoder,\n        output::{AudioSink, DefaultAudioSink},\n        resample::ResamplingQuality,\n        source::{AudioSource, ResampledSource, StereoMappedSource},\n    },\n    error::Error,\n};\n\nuse super::{\n    file::{MediaFile, MediaPath},\n    LoadedPlaybackItem, PlayerEvent,\n};\n\npub struct PlaybackManager {\n    sink: DefaultAudioSink,\n    event_send: Sender<PlayerEvent>,\n    current: Option<(MediaPath, Sender<Msg>)>,\n}\n\nimpl PlaybackManager {\n    pub fn new(sink: DefaultAudioSink, event_send: Sender<PlayerEvent>) -> Self {\n        Self {\n            sink,\n            event_send,\n            current: None,\n        }\n    }\n\n    pub fn play(&mut self, loaded: LoadedPlaybackItem) {\n        let path = loaded.file.path();\n        let source = DecoderSource::new(\n            loaded.file,\n            loaded.source,\n            loaded.norm_factor,\n            self.event_send.clone(),\n        );\n        self.current = Some((path, source.actor.sender()));\n        if source.sample_rate() == self.sink.sample_rate()\n            && source.channel_count() == self.sink.channel_count()\n        {\n            // We can start playing the source right away.\n            self.sink.play(source);\n        } else {\n            // Some output streams have different sample rate than the source, so we need to\n            // resample before pushing to the sink.\n            let source = ResampledSource::new(\n                source,\n                self.sink.sample_rate(),\n                ResamplingQuality::SincMediumQuality,\n            );\n            // Source output streams also have a different channel count. Map the stereo\n            // channels and silence the others.\n            let source = StereoMappedSource::new(source, self.sink.channel_count());\n            self.sink.play(source);\n        }\n        self.sink.resume();\n    }\n\n    pub fn seek(&self, position: Duration) {\n        if let Some((path, worker)) = &self.current {\n            let _ = worker.send(Msg::Seek(position));\n\n            // Because the position events are sent in the `DecoderSource`, doing this here\n            // is slightly hacky. The alternative would be propagating `event_send` into the\n            // worker.\n            let _ = self.event_send.send(PlayerEvent::Position {\n                path: path.to_owned(),\n                position,\n            });\n        }\n    }\n}\n\npub struct DecoderSource {\n    file: MediaFile,\n    actor: ActorHandle<Msg>,\n    consumer: Consumer<f32>,\n    event_send: Sender<PlayerEvent>,\n    total_samples: Arc<AtomicU64>,\n    position: Arc<AtomicU64>,\n    precision: u64,\n    reported: u64,\n    end_of_track: bool,\n    norm_factor: f32,\n    signal_spec: SignalSpec,\n    time_base: TimeBase,\n}\n\nimpl DecoderSource {\n    pub fn new(\n        file: MediaFile,\n        decoder: AudioDecoder,\n        norm_factor: f32,\n        event_send: Sender<PlayerEvent>,\n    ) -> Self {\n        const REPORT_PRECISION: Duration = Duration::from_millis(900);\n\n        // Gather the source signal parameters and compute how often we should report\n        // the play-head position.\n        let signal_spec = decoder.signal_spec();\n        let time_base = decoder.codec_params().time_base.unwrap();\n        let precision = (signal_spec.rate as f64\n            * signal_spec.channels.count() as f64\n            * REPORT_PRECISION.as_secs_f64()) as u64;\n\n        // Create a ring-buffer for the decoded samples.  Worker thread is producing,\n        // we are consuming in the `AudioSource` impl.\n        let buffer = Worker::default_buffer();\n        let consumer = buffer.consumer();\n\n        // We keep track of the current play-head position by sharing an atomic sample\n        // counter with the decoding worker.  Worker is setting this on seek, we are\n        // incrementing on reading from the ring-buffer.\n        let position = Arc::new(AtomicU64::new(0));\n\n        // Because the `n_frames` count that Symphonia gives us can be a bit unreliable,\n        // we track the total number of samples in this stream in this atomic, set when\n        // the underlying decoder returns EOF.\n        let total_samples = Arc::new(AtomicU64::new(u64::MAX));\n\n        // Spawn the worker and kick-start the decoding.  The buffer will start filling\n        // now.\n        let actor = Worker::spawn_with_default_cap(\"audio_decoding\", {\n            let position = Arc::clone(&position);\n            let total_samples = Arc::clone(&total_samples);\n            move |this| Worker::new(this, decoder, buffer, position, total_samples)\n        });\n        let _ = actor.send(Msg::Read);\n\n        Self {\n            file,\n            actor,\n            consumer,\n            event_send,\n            norm_factor,\n            signal_spec,\n            time_base,\n            total_samples,\n            end_of_track: false,\n            position,\n            precision,\n            reported: u64::MAX, // Something sufficiently distinct from any position.\n        }\n    }\n\n    fn written_samples(&self, position: u64) -> u64 {\n        self.position.fetch_add(position, Ordering::Relaxed) + position\n    }\n\n    fn should_report(&self, pos: u64) -> bool {\n        self.reported > pos || pos - self.reported >= self.precision\n    }\n\n    fn samples_to_duration(&self, samples: u64) -> Duration {\n        let frames = samples / self.signal_spec.channels.count() as u64;\n        let time = self.time_base.calc_time(frames);\n        Duration::from_secs(time.seconds) + Duration::from_secs_f64(time.frac)\n    }\n}\n\nimpl AudioSource for DecoderSource {\n    fn write(&mut self, output: &mut [f32]) -> usize {\n        if self.end_of_track {\n            return 0;\n        }\n        let written = self.consumer.read(output).unwrap_or(0);\n\n        // Apply the normalization factor.\n        output[..written]\n            .iter_mut()\n            .for_each(|s| *s *= self.norm_factor);\n\n        let position = self.written_samples(written as u64);\n        if self.should_report(position) {\n            // Send a position report, so the upper layers can visualize the playback\n            // progress and preload the next track.  We cannot block here, so if the channel\n            // is full, we just try the next time instead of waiting.\n            if self\n                .event_send\n                .try_send(PlayerEvent::Position {\n                    path: self.file.path(),\n                    position: self.samples_to_duration(position),\n                })\n                .is_ok()\n            {\n                self.reported = position;\n            }\n        }\n\n        let total_samples = self.total_samples.load(Ordering::Relaxed);\n        if position >= total_samples {\n            // After reading the total number of samples, we stop. Signal to the upper layer\n            // this track is over and short-circuit all further reads from this source.\n            if self.event_send.try_send(PlayerEvent::EndOfTrack).is_ok() {\n                self.end_of_track = true;\n            }\n        }\n\n        written\n    }\n\n    fn channel_count(&self) -> usize {\n        self.signal_spec.channels.count()\n    }\n\n    fn sample_rate(&self) -> u32 {\n        self.signal_spec.rate\n    }\n}\n\nimpl Drop for DecoderSource {\n    fn drop(&mut self) {\n        let _ = self.actor.send(Msg::Stop);\n    }\n}\n\nenum Msg {\n    Seek(Duration),\n    Read,\n    Stop,\n}\n\nstruct Worker {\n    /// Sending part of our own actor channel.\n    this: Sender<Msg>,\n    /// Decoder we are reading packets/samples from.\n    input: AudioDecoder,\n    /// Audio properties of the decoded signal.\n    input_spec: SignalSpec,\n    /// Sample buffer containing samples read in the last packet.\n    input_packet: SampleBuffer<f32>,\n    /// Ring-buffer for the output signal.\n    output: SpscRb<f32>,\n    /// Producing part of the output ring-buffer.\n    output_producer: Producer<f32>,\n    /// Shared atomic position.  We update this on seek only.\n    position: Arc<AtomicU64>,\n    /// Shared atomic for total number of samples.  We set this on EOF.\n    total_samples: Arc<AtomicU64>,\n    /// Range of samples in `resampled` that are awaiting flush into `output`.\n    samples_to_write: Range<usize>,\n    /// Number of samples written into the output channel.\n    samples_written: u64,\n    /// Are we in the middle of automatic read loop?\n    is_reading: bool,\n}\n\nimpl Worker {\n    fn default_buffer() -> SpscRb<f32> {\n        const DEFAULT_BUFFER_SIZE: usize = 128 * 1024;\n\n        SpscRb::new(DEFAULT_BUFFER_SIZE)\n    }\n\n    fn new(\n        this: Sender<Msg>,\n        input: AudioDecoder,\n        output: SpscRb<f32>,\n        position: Arc<AtomicU64>,\n        total_samples: Arc<AtomicU64>,\n    ) -> Self {\n        const DEFAULT_MAX_FRAMES: u64 = 8 * 1024;\n\n        let max_input_frames = input\n            .codec_params()\n            .max_frames_per_packet\n            .unwrap_or(DEFAULT_MAX_FRAMES);\n\n        // Promote the worker thread to audio priority to prevent buffer under-runs on\n        // high CPU usage.\n        if let Err(err) =\n            audio_thread_priority::promote_current_thread_to_real_time(0, input.signal_spec().rate)\n        {\n            log::warn!(\"failed to promote thread to audio priority: {err}\");\n        }\n\n        Self {\n            output_producer: output.producer(),\n            input_packet: SampleBuffer::new(max_input_frames, input.signal_spec()),\n            input_spec: input.signal_spec(),\n            input,\n            this,\n            output,\n            position,\n            total_samples,\n            samples_written: 0,\n            samples_to_write: 0..0, // Arbitrary empty range.\n            is_reading: false,\n        }\n    }\n}\n\nimpl Actor for Worker {\n    type Message = Msg;\n    type Error = Error;\n\n    fn handle(&mut self, msg: Msg) -> Result<Act<Self>, Self::Error> {\n        match msg {\n            Msg::Seek(time) => self.on_seek(time),\n            Msg::Read => self.on_read(),\n            Msg::Stop => Ok(Act::Shutdown),\n        }\n    }\n}\n\nimpl Worker {\n    fn on_seek(&mut self, time: Duration) -> Result<Act<Self>, Error> {\n        match self.input.seek(time) {\n            Ok(timestamp) => {\n                if self.is_reading {\n                    self.samples_to_write = 0..0;\n                } else {\n                    self.this.send(Msg::Read)?;\n                }\n                let position = timestamp * self.input_spec.channels.count() as u64;\n                self.samples_written = position;\n                self.position.store(position, Ordering::Relaxed);\n                self.output.clear();\n            }\n            Err(err) => {\n                log::error!(\"failed to seek: {err}\");\n            }\n        }\n        Ok(Act::Continue)\n    }\n\n    fn on_read(&mut self) -> Result<Act<Self>, Error> {\n        if !self.samples_to_write.is_empty() {\n            let writable = &self.input_packet.samples()[self.samples_to_write.clone()];\n            if let Ok(written) = self.output_producer.write(writable) {\n                self.samples_written += written as u64;\n                self.samples_to_write.start += written;\n                self.is_reading = true;\n                self.this.send(Msg::Read)?;\n                Ok(Act::Continue)\n            } else {\n                // Buffer is full.  Wait a bit a try again.  We also have to indicate that the\n                // read loop is not running at the moment (if we receive a `Seek` while waiting,\n                // we need it to explicitly kickstart reading again).\n                self.is_reading = false;\n                Ok(Act::WaitOr {\n                    timeout: Duration::from_millis(500),\n                    timeout_msg: Msg::Read,\n                })\n            }\n        } else {\n            match self.input.read_packet(&mut self.input_packet) {\n                Some(_) => {\n                    self.samples_to_write = 0..self.input_packet.samples().len();\n                    self.is_reading = true;\n                    self.this.send(Msg::Read)?;\n                }\n                None => {\n                    self.is_reading = false;\n                    self.total_samples\n                        .store(self.samples_written, Ordering::Relaxed);\n                }\n            }\n            Ok(Act::Continue)\n        }\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/access_token.rs",
    "content": "use std::time::{Duration, Instant};\n\nuse parking_lot::Mutex;\nuse serde::Deserialize;\n\nuse crate::error::Error;\n\nuse super::SessionService;\n\n// Client ID of the official Web Spotify front-end.\npub const CLIENT_ID: &str = \"65b708073fc0480ea92a077233ca87bd\";\n\n// All scopes we could possibly require.\npub const ACCESS_SCOPES: &str = \"streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played\";\n\n// Consider token expired even before the official expiration time.  Spotify\n// seems to be reporting excessive token TTLs so let's cut it down by 30\n// minutes.\nconst EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(60 * 30);\n\n#[derive(Clone)]\npub struct AccessToken {\n    pub token: String,\n    pub expires: Instant,\n}\n\nimpl AccessToken {\n    fn expired() -> Self {\n        Self {\n            token: String::new(),\n            expires: Instant::now(),\n        }\n    }\n\n    pub fn request(session: &SessionService) -> Result<Self, Error> {\n        #[derive(Deserialize)]\n        struct MercuryAccessToken {\n            #[serde(rename = \"expiresIn\")]\n            expires_in: u64,\n            #[serde(rename = \"accessToken\")]\n            access_token: String,\n        }\n\n        let token: MercuryAccessToken = session.connected()?.get_mercury_json(format!(\n            \"hm://keymaster/token/authenticated?client_id={CLIENT_ID}&scope={ACCESS_SCOPES}\",\n        ))?;\n\n        Ok(Self {\n            token: token.access_token,\n            expires: Instant::now() + Duration::from_secs(token.expires_in),\n        })\n    }\n\n    fn is_expired(&self) -> bool {\n        self.expires.saturating_duration_since(Instant::now()) < EXPIRATION_TIME_THRESHOLD\n    }\n}\n\npub struct TokenProvider {\n    token: Mutex<AccessToken>,\n}\n\nimpl TokenProvider {\n    pub fn new() -> Self {\n        Self {\n            token: Mutex::new(AccessToken::expired()),\n        }\n    }\n\n    pub fn get(&self, session: &SessionService) -> Result<AccessToken, Error> {\n        let mut token = self.token.lock();\n        if token.is_expired() {\n            log::info!(\"access token expired, requesting\");\n            *token = AccessToken::request(session)?;\n        }\n        Ok(token.clone())\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/audio_key.rs",
    "content": "use std::{\n    collections::HashMap,\n    io::{Cursor, Read},\n};\n\nuse byteorder::{ReadBytesExt, BE};\nuse crossbeam_channel::Sender;\n\nuse crate::{\n    audio::decrypt::AudioKey,\n    connection::shannon_codec::ShannonMsg,\n    error::Error,\n    item_id::{FileId, ItemId},\n    util::Sequence,\n};\n\npub struct AudioKeyDispatcher {\n    sequence: Sequence<u32>,\n    pending: HashMap<u32, Sender<Result<AudioKey, Error>>>,\n}\n\nimpl AudioKeyDispatcher {\n    pub fn new() -> Self {\n        Self {\n            sequence: Sequence::new(0),\n            pending: HashMap::new(),\n        }\n    }\n\n    pub fn enqueue_request(\n        &mut self,\n        track: ItemId,\n        file: FileId,\n        callback: Sender<Result<AudioKey, Error>>,\n    ) -> ShannonMsg {\n        let seq = self.sequence.advance();\n        self.pending.insert(seq, callback);\n        Self::make_key_request(seq, track, file)\n    }\n\n    fn make_key_request(seq: u32, track: ItemId, file: FileId) -> ShannonMsg {\n        let mut buf = Vec::new();\n        buf.extend(file.0);\n        buf.extend(track.to_raw());\n        buf.extend(seq.to_be_bytes());\n        buf.extend(0_u16.to_be_bytes());\n        ShannonMsg::new(ShannonMsg::REQUEST_KEY, buf)\n    }\n\n    pub fn handle_aes_key(&mut self, msg: ShannonMsg) {\n        let mut payload = Cursor::new(msg.payload);\n        let seq = payload.read_u32::<BE>().unwrap();\n\n        if let Some(tx) = self.pending.remove(&seq) {\n            let mut key = [0_u8; 16];\n            payload.read_exact(&mut key).unwrap();\n\n            if tx.send(Ok(AudioKey(key))).is_err() {\n                log::warn!(\"missing receiver for audio key, seq: {seq}\");\n            }\n        } else {\n            log::warn!(\"received unexpected audio key msg, seq: {seq}\");\n        }\n    }\n\n    pub fn handle_aes_key_error(&mut self, msg: ShannonMsg) {\n        let mut payload = Cursor::new(msg.payload);\n        let seq = payload.read_u32::<BE>().unwrap();\n\n        if let Some(tx) = self.pending.remove(&seq) {\n            log::error!(\"audio key error\");\n            if tx.send(Err(Error::UnexpectedResponse)).is_err() {\n                log::warn!(\"missing receiver for audio key error, seq: {seq}\");\n            }\n        } else {\n            log::warn!(\"received unknown audio key, seq: {seq}\");\n        }\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/client_token.rs",
    "content": "// Ported from librespot\n\nuse crate::error::Error;\nuse crate::session::token::{Token};\nuse crate::util::{default_ureq_agent_builder, solve_hash_cash};\nuse data_encoding::HEXUPPER_PERMISSIVE;\nuse librespot_protocol::clienttoken_http::{\n    ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,\n    ClientTokenResponse, ClientTokenResponseType,\n};\nuse parking_lot::Mutex;\nuse protobuf::{Enum, Message};\nuse std::time::{Duration, Instant};\nuse crate::system_info::{CLIENT_ID, DEVICE_ID, OS, SPOTIFY_SEMANTIC_VERSION};\n\npub struct ClientTokenProvider {\n    token: Mutex<Option<Token>>,\n    agent: ureq::Agent,\n}\n\nimpl ClientTokenProvider {\n    pub fn new(proxy_url: Option<&str>) -> Self {\n        Self {\n            token: Mutex::new(None),\n            agent: default_ureq_agent_builder(proxy_url).build().into(),\n        }\n    }\n\n    fn request<M: Message>(&self, message: &M) -> Result<Vec<u8>, Error> {\n        let body = message.write_to_bytes()?;\n\n        let mut response = self\n            .agent\n            .post(\"https://clienttoken.spotify.com/v1/clienttoken\")\n            .header(\"Accept\", \"application/x-protobuf\")\n            .send(body)?;\n\n        let vec = response.body_mut().read_to_vec();\n        Ok(vec?)\n    }\n\n    fn request_new_token(&self) -> Result<Token, Error> {\n        log::debug!(\"Requesting new token...\");\n\n        let mut request = ClientTokenRequest::new();\n        request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into();\n\n        let client_data = request.mut_client_data();\n\n        client_data.client_version = SPOTIFY_SEMANTIC_VERSION.into();\n        client_data.client_id = CLIENT_ID.into();\n\n        let connectivity_data = client_data.mut_connectivity_sdk_data();\n        connectivity_data.device_id = DEVICE_ID.to_string();\n\n        let platform_data = connectivity_data\n            .platform_specific_data\n            .mut_or_insert_default();\n\n        let os_version = sysinfo::System::os_version().unwrap_or(\"0\".into());\n        let kernel_version = sysinfo::System::kernel_version().unwrap_or_else(|| String::from(\"0\"));\n\n        match OS {\n            \"windows\" => {\n                let os_version = os_version.parse::<f32>().unwrap_or(10.) as i32;\n                let kernel_version = kernel_version.parse::<i32>().unwrap_or(21370);\n\n                let (pe, image_file) = match std::env::consts::ARCH {\n                    \"arm\" => (448, 452),\n                    \"aarch64\" => (43620, 452),\n                    \"x86_64\" => (34404, 34404),\n                    _ => (332, 332), // x86\n                };\n\n                let windows_data = platform_data.mut_desktop_windows();\n                windows_data.os_version = os_version;\n                windows_data.os_build = kernel_version;\n                windows_data.platform_id = 2;\n                windows_data.unknown_value_6 = 9;\n                windows_data.image_file_machine = image_file;\n                windows_data.pe_machine = pe;\n                windows_data.unknown_value_10 = true;\n            }\n            \"ios\" => {\n                let ios_data = platform_data.mut_ios();\n                ios_data.user_interface_idiom = 0;\n                ios_data.target_iphone_simulator = false;\n                ios_data.hw_machine = \"iPhone14,5\".to_string();\n                ios_data.system_version = os_version;\n            }\n            \"android\" => {\n                let android_data = platform_data.mut_android();\n                android_data.android_version = os_version;\n                android_data.api_version = 31;\n                \"Pixel\".clone_into(&mut android_data.device_name);\n                \"GF5KQ\".clone_into(&mut android_data.model_str);\n                \"Google\".clone_into(&mut android_data.vendor);\n            }\n            \"macos\" => {\n                let macos_data = platform_data.mut_desktop_macos();\n                macos_data.system_version = os_version;\n                macos_data.hw_model = \"iMac21,1\".to_string();\n                macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string();\n            }\n            _ => {\n                let linux_data = platform_data.mut_desktop_linux();\n                linux_data.system_name = \"Linux\".to_string();\n                linux_data.system_release = kernel_version;\n                linux_data.system_version = os_version;\n                linux_data.hardware = std::env::consts::ARCH.to_string();\n            }\n        }\n\n        let mut response = self.request(&request)?;\n        let mut count = 0;\n        const MAX_TRIES: u8 = 3;\n\n        let token_response = loop {\n            count += 1;\n\n            let message = ClientTokenResponse::parse_from_bytes(&response)?;\n\n            match ClientTokenResponseType::from_i32(message.response_type.value()) {\n                // depending on the platform, you're either given a token immediately\n                // or are presented a hash cash challenge to solve first\n                Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => {\n                    log::debug!(\"Received a granted token\");\n                    break message;\n                }\n                Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => {\n                    log::debug!(\"Received a hash cash challenge, solving...\");\n\n                    let challenges = message.challenges().clone();\n                    let state = challenges.state;\n                    if let Some(challenge) = challenges.challenges.first() {\n                        let hash_cash_challenge = challenge.evaluate_hashcash_parameters();\n\n                        let ctx = vec![];\n                        let prefix = HEXUPPER_PERMISSIVE\n                            .decode(hash_cash_challenge.prefix.as_bytes())\n                            .map_err(|e| {\n                                Error::InvalidStateError(\n                                    format!(\"Unable to decode hash cash challenge: {e}\").into(),\n                                )\n                            })?;\n                        let length = hash_cash_challenge.length;\n\n                        let mut suffix = [0u8; 0x10];\n                        let answer = solve_hash_cash(&ctx, &prefix, length, &mut suffix);\n\n                        match answer {\n                            Ok(_) => {\n                                // the suffix must be in uppercase\n                                let suffix = HEXUPPER_PERMISSIVE.encode(&suffix);\n\n                                let mut answer_message = ClientTokenRequest::new();\n                                answer_message.request_type =\n                                    ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST\n                                        .into();\n\n                                let challenge_answers = answer_message.mut_challenge_answers();\n\n                                let mut challenge_answer = ChallengeAnswer::new();\n                                challenge_answer.mut_hash_cash().suffix = suffix;\n                                challenge_answer.ChallengeType =\n                                    ChallengeType::CHALLENGE_HASH_CASH.into();\n\n                                challenge_answers.state = state.to_string();\n                                challenge_answers.answers.push(challenge_answer);\n\n                                log::trace!(\"Answering hash cash challenge\");\n                                match self.request(&answer_message) {\n                                    Ok(token) => {\n                                        response = token;\n                                        continue;\n                                    }\n                                    Err(e) => {\n                                        log::trace!(\"Answer not accepted {count}/{MAX_TRIES}: {e}\");\n                                    }\n                                }\n                            }\n                            Err(e) => log::trace!(\n                                \"Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}\"\n                            ),\n                        }\n\n                        if count < MAX_TRIES {\n                            response = self.request(&request)?;\n                        } else {\n                            return Err(Error::InvalidStateError(\n                                format!(\"Unable to solve any of {MAX_TRIES} hash cash challenges\")\n                                    .into(),\n                            ));\n                        }\n                    } else {\n                        return Err(Error::InvalidStateError(\"No challenges found\".into()));\n                    }\n                }\n\n                Some(unknown) => {\n                    return Err(Error::UnimplementedError(\n                        format!(\"Unknown client token response type: {unknown:?}\").into(),\n                    ));\n                }\n                None => {\n                    return Err(Error::InvalidStateError(\n                        \"No client token response type\".into(),\n                    ))\n                }\n            }\n        };\n\n        let granted_token = token_response.granted_token();\n        let access_token = granted_token.token.to_owned();\n\n        Ok(Token {\n            access_token: access_token.clone(),\n            expires_in: Duration::from_secs(\n                granted_token\n                    .refresh_after_seconds\n                    .try_into()\n                    .unwrap_or(7200),\n            ),\n            token_type: \"client-token\".to_string(),\n            scopes: granted_token\n                .domains\n                .iter()\n                .map(|d| d.domain.clone())\n                .collect(),\n            timestamp: Instant::now(),\n        })\n    }\n\n    pub fn get(&self) -> Result<String, Error> {\n        // Check for cached token availability, else retrieve fresh token\n        let mut cur_token = self.token.lock();\n\n        if let Some(token) = &*cur_token {\n            if !token.is_expired() {\n                return Ok(token.access_token.clone());\n            }\n\n            *cur_token = None;\n            log::debug!(\"Client token expired\");\n        }\n\n        let new_token = self.request_new_token()?;\n\n        *cur_token = Some(new_token.clone());\n        Ok(new_token.access_token)\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/login5.rs",
    "content": "// Ported from librespot\n\nuse crate::error::Error;\nuse crate::session::client_token::ClientTokenProvider;\nuse crate::session::token::Token;\nuse crate::session::SessionService;\nuse crate::system_info::{CLIENT_ID, DEVICE_ID};\nuse crate::util::{default_ureq_agent_builder, solve_hash_cash};\nuse librespot_protocol::login5::login_response::Response;\nuse librespot_protocol::{\n    client_info::ClientInfo,\n    credentials::StoredCredential,\n    hashcash::HashcashSolution,\n    login5::{\n        login_request::Login_method, ChallengeSolution, LoginError, LoginRequest, LoginResponse,\n    },\n};\nuse parking_lot::Mutex;\nuse protobuf::well_known_types::duration::Duration as ProtoDuration;\nuse protobuf::{Message, MessageField};\nuse std::fmt::Formatter;\nuse std::time::{Duration, Instant};\nuse std::{error, fmt, thread};\n\nconst MAX_LOGIN_TRIES: u8 = 3;\nconst LOGIN_TIMEOUT: Duration = Duration::from_secs(3);\n\n#[derive(Debug)]\npub enum ChallengeError {\n    Unsupported,\n    NoneFound,\n}\n\n#[derive(Debug)]\nenum Login5Error {\n    /// The server denied the request with a specific error code.\n    RequestDenied(LoginError),\n    /// The server issued a challenge that we could not solve.\n    Challenge(ChallengeError),\n    /// The operation could not be performed due to invalid local state.\n    InvalidState(String),\n    /// The login attempt failed after multiple retries.\n    RetriesExceeded(u8),\n    /// The server's response was malformed or missing expected fields.\n    MalformedResponse,\n}\n\nimpl error::Error for Login5Error {}\n\nimpl fmt::Display for Login5Error {\n    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {\n        match self {\n            Login5Error::RequestDenied(e) => write!(f, \"Login request was denied: {:?}\", e),\n            Login5Error::Challenge(c) => match c {\n                ChallengeError::Unsupported => write!(f, \"Login5 code challenge is not supported\"),\n                ChallengeError::NoneFound => write!(f, \"No challenges found in response\"),\n            },\n            Login5Error::InvalidState(s) => write!(f, \"Invalid state: {}\", s),\n            Login5Error::RetriesExceeded(n) => {\n                write!(f, \"Couldn't successfully authenticate after {} times\", n)\n            }\n            Login5Error::MalformedResponse => write!(f, \"Missing response from login server\"),\n        }\n    }\n}\n\nimpl From<Login5Error> for Error {\n    fn from(err: Login5Error) -> Self {\n        match err {\n            Login5Error::RequestDenied(_)\n            | Login5Error::InvalidState(_)\n            | Login5Error::RetriesExceeded(_)\n            | Login5Error::MalformedResponse => Error::InvalidStateError(err.into()),\n            Login5Error::Challenge(_) => Error::UnimplementedError(err.into()),\n        }\n    }\n}\n\npub struct Login5 {\n    auth_token: Mutex<Option<Token>>,\n    client_token_provider: ClientTokenProvider,\n    agent: ureq::Agent,\n}\n\nimpl Login5 {\n    /// Login5 instances can be used to cache and retrieve access tokens from stored credentials.\n    ///\n    /// # Arguments\n    ///\n    /// * `client_token_provider`: Can be optionally injected to control which client-id is\n    ///   used for it.\n    ///\n    /// returns: Login5\n    pub fn new(\n        client_token_provider: Option<ClientTokenProvider>,\n        proxy_url: Option<&str>,\n    ) -> Self {\n        Self {\n            auth_token: Mutex::new(None),\n            client_token_provider: client_token_provider\n                .unwrap_or_else(|| ClientTokenProvider::new(proxy_url)),\n            agent: default_ureq_agent_builder(proxy_url).build().into(),\n        }\n    }\n\n    fn request(&self, message: &LoginRequest) -> Result<Vec<u8>, Error> {\n        let client_token: String = self.client_token_provider.get()?;\n        let body = message.write_to_bytes()?;\n\n        let mut response = self\n            .agent\n            .post(\"https://login5.spotify.com/v3/login\")\n            .header(\"Accept\", \"application/x-protobuf\")\n            .header(\"client-token\", &client_token)\n            .send(body)?;\n\n        let vec = response.body_mut().read_to_vec()?;\n        Ok(vec)\n    }\n\n    fn request_new_token(&self, login: Login_method) -> Result<Token, Error> {\n        let mut login_request = LoginRequest {\n            client_info: MessageField::some(ClientInfo {\n                client_id: String::from(CLIENT_ID),\n                device_id: String::from(DEVICE_ID),\n                special_fields: Default::default(),\n            }),\n            login_method: Some(login),\n            ..Default::default()\n        };\n\n        let mut response = self.request(&login_request)?;\n        let mut count = 0;\n\n        loop {\n            count += 1;\n\n            let mut message = LoginResponse::parse_from_bytes(&response)?;\n            match message.response.take() {\n                Some(Response::Ok(ok)) => {\n                    let expiry_secs = ok.access_token_expires_in.try_into().unwrap_or(3600);\n                    return Ok(Token {\n                        access_token: ok.access_token,\n                        expires_in: Duration::from_secs(expiry_secs),\n                        token_type: \"Bearer\".to_string(),\n                        scopes: vec![],\n                        timestamp: Instant::now(),\n                    });\n                }\n                Some(Response::Error(err)) => match err.enum_value() {\n                    Ok(LoginError::TIMEOUT) | Ok(LoginError::TOO_MANY_ATTEMPTS) => {\n                        log::debug!(\"Too many login5 requests... timeout!\");\n                        thread::sleep(LOGIN_TIMEOUT)\n                    }\n                    Ok(other) => {\n                        log::debug!(\"Login5 request failed!\");\n                        return Err(Login5Error::RequestDenied(other).into());\n                    }\n                    Err(other) => {\n                        log::warn!(\"Unknown login error: {}\", other);\n                    }\n                },\n                Some(Response::Challenges(_)) => {\n                    // handles the challenges, and updates the login context with the response\n                    Self::handle_challenges(&mut login_request, message)?;\n                }\n                None => {\n                    return Err(Login5Error::MalformedResponse.into());\n                }\n                _ => {\n                    log::warn!(\"Unhandled login response\");\n                }\n            }\n\n            if count < MAX_LOGIN_TRIES {\n                response = self.request(&login_request)?;\n            } else {\n                return Err(Login5Error::RetriesExceeded(MAX_LOGIN_TRIES).into());\n            }\n        }\n    }\n\n    fn handle_challenges(\n        login_request: &mut LoginRequest,\n        message: LoginResponse,\n    ) -> Result<(), Error> {\n        let challenges = message.challenges();\n        log::debug!(\n            \"Received {} challenges, solving...\",\n            challenges.challenges.len()\n        );\n\n        if challenges.challenges.is_empty() {\n            return Err(Login5Error::Challenge(ChallengeError::NoneFound).into());\n        }\n\n        for challenge in &challenges.challenges {\n            if challenge.has_code() || !challenge.has_hashcash() {\n                // We only solve hashcash challenges.\n                return Err(Login5Error::Challenge(ChallengeError::Unsupported).into());\n            }\n\n            let hash_cash_challenge = challenge.hashcash();\n\n            let mut suffix = [0u8; 0x10];\n            let duration = solve_hash_cash(\n                &message.login_context,\n                &hash_cash_challenge.prefix,\n                hash_cash_challenge.length,\n                &mut suffix,\n            )?;\n            let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32);\n            log::debug!(\"Solving hashcash took {seconds}s {nanos}ns\");\n\n            let mut solution = ChallengeSolution::new();\n            solution.set_hashcash(HashcashSolution {\n                suffix: Vec::from(suffix),\n                duration: MessageField::some(ProtoDuration {\n                    seconds,\n                    nanos,\n                    ..Default::default()\n                }),\n                ..Default::default()\n            });\n\n            login_request\n                .challenge_solutions\n                .mut_or_insert_default()\n                .solutions\n                .push(solution);\n        }\n\n        login_request.login_context = message.login_context;\n\n        Ok(())\n    }\n\n    /// Retrieve an `access_token` via Login5. The token is either requested first (slow), or\n    /// retrieved from local cache (fast).\n    ///\n    /// This request will only work if the session already has valid credentials available.\n    /// The client-id of the credentials have to match the client-id used to retrieve\n    /// the client token (see also `Login5::new(...)`). For example, if you previously generated\n    /// stored credentials with an android client-id, they won't work within login5 using a desktop\n    /// client-id.\n    pub fn get_access_token(&self, session: &SessionService) -> Result<Token, Error> {\n        let mut cur_token = self.auth_token.lock();\n\n        let login_creds = session.config.lock().as_ref().unwrap().login_creds.clone();\n        let auth_data = login_creds.auth_data.clone();\n        if auth_data.is_empty() {\n            return Err(Login5Error::InvalidState(\n                \"Tried to acquire access token without stored credentials\".to_string(),\n            )\n            .into());\n        }\n\n        if let Some(auth_token) = &*cur_token {\n            if !auth_token.is_expired() {\n                return Ok(auth_token.clone());\n            }\n\n            *cur_token = None;\n            log::debug!(\"Auth token expired\");\n        }\n\n        log::debug!(\"Requesting new auth token\");\n\n        // Conversion from psst protocol structs to librespot protocol structs\n        let method = Login_method::StoredCredential(StoredCredential {\n            username: login_creds.username.clone().unwrap(),\n            data: auth_data,\n            ..Default::default()\n        });\n\n        let new_token = self.request_new_token(method)?;\n\n        log::debug!(\"Successfully requested new auth token\");\n\n        *cur_token = Some(new_token.clone());\n        Ok(new_token)\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/mercury.rs",
    "content": "use std::{\n    collections::HashMap,\n    io::{Cursor, Read},\n};\n\nuse byteorder::{ReadBytesExt, BE};\nuse crossbeam_channel::Sender;\n\nuse crate::{connection::shannon_codec::ShannonMsg, util::Sequence};\n\nuse librespot_protocol::mercury::Header;\nuse protobuf::Message;\n\npub struct MercuryDispatcher {\n    sequence: Sequence<u64>,\n    pending: HashMap<u64, Pending>,\n}\n\nimpl MercuryDispatcher {\n    pub fn new() -> Self {\n        Self {\n            sequence: Sequence::new(0),\n            pending: HashMap::new(),\n        }\n    }\n\n    pub fn enqueue_request(\n        &mut self,\n        req: MercuryRequest,\n        callback: Sender<MercuryResponse>,\n    ) -> ShannonMsg {\n        let seq = self.sequence.advance();\n        self.pending.insert(\n            seq,\n            Pending {\n                callback,\n                messages: Vec::new(),\n            },\n        );\n        ShannonMsg::new(ShannonMsg::MERCURY_REQ, req.encode_to_mercury_message(seq))\n    }\n\n    pub fn handle_mercury_req(&mut self, shannon_msg: ShannonMsg) {\n        let msg = Msg::decode(shannon_msg.payload);\n        let msg_flags = msg.flags;\n        let msg_seq = msg.seq;\n        if let Some(mut pending) = self.pending.remove(&msg_seq) {\n            pending.messages.push(msg);\n            if msg_flags == Msg::FINAL {\n                // This is the final message.  Aggregate all pending parts and process further.\n                let parts = Msg::aggregate(pending.messages);\n                let response = MercuryResponse::decode_from_parts(parts);\n                // Send the response.  If the response channel is closed, ignore it.\n                let _ = pending.callback.send(response);\n            } else {\n                // This is not the final message of this sequence, but it back as pending.\n                self.pending.insert(msg_seq, pending);\n            }\n        } else {\n            log::warn!(\"received unexpected mercury msg, seq: {msg_seq}\");\n        }\n    }\n}\n\n#[derive(Debug)]\npub struct MercuryRequest {\n    pub uri: String,\n    pub method: String,\n    pub payload: Vec<Vec<u8>>,\n}\n\nimpl MercuryRequest {\n    pub fn get(uri: String) -> Self {\n        Self {\n            uri,\n            method: \"GET\".to_string(),\n            payload: Vec::new(),\n        }\n    }\n\n    pub fn send(uri: String, data: Vec<u8>) -> Self {\n        Self {\n            uri,\n            method: \"SEND\".to_string(),\n            payload: vec![data],\n        }\n    }\n\n    fn encode_to_mercury_message(self, seq: u64) -> Vec<u8> {\n        let parts = self.encode_to_parts();\n        let msg = Msg::new(seq, Msg::FINAL, parts);\n        msg.encode()\n    }\n\n    fn encode_to_parts(self) -> Vec<Vec<u8>> {\n        let header = Header {\n            uri: Some(self.uri),\n            method: Some(self.method),\n            ..Header::default()\n        };\n        let header_part = header\n            .write_to_bytes()\n            .expect(\"Failed to serialize message header\");\n\n        let mut parts = self.payload;\n        parts.insert(0, header_part);\n        parts\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct MercuryResponse {\n    pub uri: String,\n    pub status_code: i32,\n    pub payload: Vec<Vec<u8>>,\n}\n\nimpl MercuryResponse {\n    fn decode_from_parts(mut parts: Vec<Vec<u8>>) -> Self {\n        let header_part = parts.remove(0);\n        let header = Header::parse_from_bytes(&header_part)\n            .expect(\"Failed to deserialize message header\");\n\n        Self {\n            uri: header.uri.unwrap(),\n            status_code: header.status_code.unwrap(),\n            payload: parts,\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct Pending {\n    messages: Vec<Msg>,\n    callback: Sender<MercuryResponse>,\n}\n\n#[derive(Debug, Default)]\nstruct Msg {\n    seq: u64,\n    flags: u8,\n    count: u16,\n    parts: Vec<Vec<u8>>,\n}\n\nimpl Msg {\n    const FINAL: u8 = 0x01;\n    const PARTIAL: u8 = 0x02;\n\n    fn new(seq: u64, flags: u8, parts: Vec<Vec<u8>>) -> Self {\n        let count = parts.len() as u16;\n        Self {\n            seq,\n            flags,\n            count,\n            parts,\n        }\n    }\n\n    fn decode(buf: Vec<u8>) -> Self {\n        let mut buf = Cursor::new(buf);\n        let seq_len = buf.read_u16::<BE>().unwrap();\n        let seq = buf.read_uint::<BE>(seq_len.into()).unwrap();\n        let flags = buf.read_u8().unwrap();\n        let count = buf.read_u16::<BE>().unwrap();\n        let mut parts = Vec::with_capacity(count.into());\n        for _ in 0..count {\n            let part_len = buf.read_u16::<BE>().unwrap();\n            let mut part = vec![0_u8; part_len.into()];\n            buf.read_exact(&mut part).unwrap();\n            parts.push(part);\n        }\n        Self {\n            seq,\n            flags,\n            count,\n            parts,\n        }\n    }\n\n    fn encode(&self) -> Vec<u8> {\n        let mut buf = Vec::new();\n        buf.extend(8_u16.to_be_bytes()); // Sequence length.\n        buf.extend(self.seq.to_be_bytes());\n        buf.push(self.flags);\n        buf.extend(self.count.to_be_bytes());\n        for part in &self.parts {\n            let len = part.len() as u16;\n            buf.extend(len.to_be_bytes());\n            buf.extend(part);\n        }\n        buf\n    }\n\n    fn aggregate(msgs: impl IntoIterator<Item = Self>) -> Vec<Vec<u8>> {\n        let mut results = Vec::new();\n        let mut partial: Option<Vec<u8>> = None;\n\n        for msg in msgs {\n            for (i, mut part) in msg.parts.into_iter().enumerate() {\n                // If we have a partial data left from the last message, append to it.\n                if let Some(mut partial) = partial.take() {\n                    partial.extend(part);\n                    part = partial;\n                }\n\n                // Save the last part of partial messages for later.\n                let is_last_part = i as u16 == msg.count - 1;\n                if msg.flags == Self::PARTIAL && is_last_part {\n                    partial = Some(part);\n                } else {\n                    results.push(part);\n                }\n            }\n        }\n\n        results\n    }\n}"
  },
  {
    "path": "psst-core/src/session/mod.rs",
    "content": "pub mod access_token;\npub mod audio_key;\npub mod mercury;\npub mod login5;\npub mod client_token;\npub mod token;\n\nuse std::{\n    io,\n    net::{Shutdown, TcpStream},\n    sync::{\n        atomic::{AtomicBool, Ordering},\n        Arc,\n    },\n    thread::{self, JoinHandle},\n};\n\nuse crossbeam_channel::{unbounded, Receiver, Sender};\nuse parking_lot::Mutex;\nuse serde::de::DeserializeOwned;\n\nuse crate::{\n    audio::decrypt::AudioKey,\n    connection::{\n        shannon_codec::{ShannonDecoder, ShannonEncoder, ShannonMsg},\n        Credentials, Transport,\n    },\n    error::Error,\n    item_id::{FileId, ItemId},\n};\n\nuse self::{\n    audio_key::AudioKeyDispatcher,\n    mercury::{MercuryDispatcher, MercuryRequest, MercuryResponse},\n};\n\n/// Configuration values needed to open the session connection.\n#[derive(Clone)]\npub struct SessionConfig {\n    pub login_creds: Credentials,\n    pub proxy_url: Option<String>,\n}\n\n/// Cheap to clone, shareable service handle that holds the active session\n/// worker.  Session connection is lazily opened in  `connected()`, using config\n/// values set in `update_config()`.  In case the session dies or is explicitly\n/// shut down, worker is disposed of, and a new session is opened on the next\n/// request.\n#[derive(Clone)]\npub struct SessionService {\n    connected: Arc<Mutex<Option<SessionWorker>>>,\n    config: Arc<Mutex<Option<SessionConfig>>>,\n}\n\nimpl SessionService {\n    /// Create a new session service without any configuration.  To open a\n    /// session, a config needs to be set up first using `update_config`.\n    pub fn empty() -> Self {\n        Self {\n            connected: Arc::default(),\n            config: Arc::default(),\n        }\n    }\n\n    /// Create a new session service with pre-set configuration.\n    pub fn with_config(config: SessionConfig) -> Self {\n        Self {\n            connected: Arc::default(),\n            config: Arc::new(Mutex::new(Some(config))),\n        }\n    }\n\n    /// Replace the active session config.  If a session is already connected,\n    /// shut it down and wait until it's terminated.\n    pub fn update_config(&self, config: SessionConfig) {\n        self.config.lock().replace(config);\n        self.shutdown();\n    }\n\n    /// Returns true if a session worker is actively servicing the connected\n    /// session.  We return false here after any case of I/O errors or an\n    /// explicit session shutdown.\n    pub fn is_connected(&self) -> bool {\n        matches!(self.connected.lock().as_ref(), Some(worker) if !worker.has_terminated())\n    }\n\n    /// Return a handle for the connected session.  In case no connection is\n    /// open, *synchronously* connect, start the worker and keep it as active.\n    /// Although a lock is held for the whole duration  of connection setup,\n    /// `SessionConnection::open` has an internal timeout, and should give up in\n    /// a timely manner.\n    pub fn connected(&self) -> Result<SessionHandle, Error> {\n        let mut connected = self.connected.lock();\n        let is_connected_and_not_terminated =\n            matches!(connected.as_ref(), Some(worker) if !worker.has_terminated());\n        if !is_connected_and_not_terminated {\n            let connection = SessionConnection::open(\n                self.config\n                    .lock()\n                    .as_ref()\n                    .ok_or(Error::SessionDisconnected)?\n                    .clone(),\n            )?;\n            let worker = SessionWorker::run(connection.transport);\n            connected.replace(worker);\n        }\n        connected\n            .as_ref()\n            .map(SessionWorker::handle)\n            .ok_or(Error::SessionDisconnected)\n    }\n\n    /// Signal a shutdown to the active worker and wait until it terminates.\n    pub fn shutdown(&self) {\n        if let Some(worker) = self.connected.lock().take() {\n            worker.handle().request_shutdown();\n            worker.join();\n        }\n    }\n}\n\n/// Successful connection through the Spotify Shannon-encrypted TCP channel.\npub struct SessionConnection {\n    /// Credentials re-usable in the next authentication (i.e. username and\n    /// password are not required anymore).\n    pub credentials: Credentials,\n    /// I/O codec for the Shannon messages.\n    pub transport: Transport,\n}\n\nimpl SessionConnection {\n    /// Synchronously connect to the Spotify servers and authenticate with\n    /// credentials provided in `config`.\n    pub fn open(config: SessionConfig) -> Result<Self, Error> {\n        // Connect to the server and exchange keys.\n        let proxy_url = config.proxy_url.as_deref();\n        let ap_list = Transport::resolve_ap_with_fallback(proxy_url);\n        let mut transport = Transport::connect(&ap_list, proxy_url)?;\n        let credentials = transport.authenticate(config.login_creds)?;\n        Ok(Self {\n            credentials,\n            transport,\n        })\n    }\n}\n\npub struct SessionWorker {\n    sender: Sender<DispatchCmd>,\n    decoding_thread: JoinHandle<()>,\n    encoding_thread: JoinHandle<()>,\n    dispatching_thread: JoinHandle<()>,\n    terminated: Arc<AtomicBool>,\n}\n\nimpl SessionWorker {\n    pub fn run(transport: Transport) -> Self {\n        let (disp_send, disp_recv) = unbounded();\n        let (msg_send, msg_recv) = unbounded();\n        let terminated = Arc::new(AtomicBool::new(false));\n        Self {\n            decoding_thread: {\n                let decoder = transport.decoder;\n                let disp_send = disp_send.clone();\n                thread::spawn(move || decode_shannon_messages(decoder, disp_send))\n            },\n            encoding_thread: {\n                let encoder = transport.encoder;\n                let disp_send = disp_send.clone();\n                thread::spawn(move || encode_shannon_messages(encoder, msg_recv, disp_send))\n            },\n            dispatching_thread: {\n                let stream = transport.stream;\n                let terminated = terminated.clone();\n                thread::spawn(move || {\n                    dispatch_messages(disp_recv, msg_send, stream);\n                    terminated.store(true, Ordering::SeqCst);\n                })\n            },\n            sender: disp_send,\n            terminated,\n        }\n    }\n\n    pub fn handle(&self) -> SessionHandle {\n        SessionHandle {\n            sender: self.sender.clone(),\n        }\n    }\n\n    pub fn join(self) {\n        if let Err(err) = self.dispatching_thread.join() {\n            log::error!(\"session dispatching thread panicked: {err:?}\");\n        }\n        if let Err(err) = self.encoding_thread.join() {\n            log::error!(\"session encoding thread panicked: {err:?}\");\n        }\n        if let Err(err) = self.decoding_thread.join() {\n            log::error!(\"session decoding thread panicked: {err:?}\");\n        }\n    }\n\n    pub fn has_terminated(&self) -> bool {\n        self.terminated.load(Ordering::SeqCst)\n    }\n}\n\n#[derive(Clone)]\npub struct SessionHandle {\n    sender: Sender<DispatchCmd>,\n}\n\nimpl SessionHandle {\n    pub fn get_mercury_protobuf<T>(&self, uri: String) -> Result<T, Error>\n    where\n        T: protobuf::Message,\n    {\n        let payload = self.get_mercury_bytes(uri)?;\n        let message = T::parse_from_bytes(&payload)?;\n        Ok(message)\n    }\n\n    pub fn get_mercury_json<T>(&self, uri: String) -> Result<T, Error>\n    where\n        T: DeserializeOwned,\n    {\n        let payload = self.get_mercury_bytes(uri)?;\n        let message = serde_json::from_slice(&payload)?;\n        Ok(message)\n    }\n\n    pub fn get_mercury_bytes(&self, uri: String) -> Result<Vec<u8>, Error> {\n        let (callback, receiver) = unbounded();\n        let request = MercuryRequest::get(uri);\n        self.sender\n            .send(DispatchCmd::MercuryReq { callback, request })\n            .ok()\n            .ok_or(Error::SessionDisconnected)?;\n        let response = receiver.recv().ok().ok_or(Error::SessionDisconnected)?;\n        let first_part = response\n            .payload\n            .into_iter()\n            .next()\n            .ok_or(Error::UnexpectedResponse)?;\n        Ok(first_part)\n    }\n\n    pub fn get_audio_key(&self, track: ItemId, file: FileId) -> Result<AudioKey, Error> {\n        let (callback, receiver) = unbounded();\n        self.sender\n            .send(DispatchCmd::AudioKeyReq {\n                callback,\n                track,\n                file,\n            })\n            .ok()\n            .ok_or(Error::SessionDisconnected)?;\n        receiver.recv().ok().ok_or(Error::SessionDisconnected)?\n    }\n\n    pub fn get_country_code(&self) -> Option<String> {\n        let (callback, receiver) = unbounded();\n        self.sender\n            .send(DispatchCmd::CountryCodeReq { callback })\n            .ok()?;\n        receiver.recv().ok()?\n    }\n\n    pub fn request_shutdown(&self) {\n        let _ = self.sender.send(DispatchCmd::Shutdown);\n    }\n}\n\n/// Read Shannon messages from the TCP stream one by one and send them to\n/// dispatcher for further processing.  In case the decoding fails with an error\n/// (this happens also in case we explicitly shutdown the connection), report\n/// the error to the dispatcher and quit.  If the dispatcher has already dropped\n/// its receiving part, quit silently as well.\nfn decode_shannon_messages(mut decoder: ShannonDecoder<TcpStream>, dispatch: Sender<DispatchCmd>) {\n    loop {\n        match decoder.decode() {\n            Ok(msg) => {\n                if dispatch.send(DispatchCmd::DecodedMsg(msg)).is_err() {\n                    break;\n                }\n            }\n            Err(err) => {\n                let _ = dispatch.send(DispatchCmd::DecoderError(err));\n                break;\n            }\n        };\n    }\n}\n\n/// Receive Shannon messages from `messages` and encode them into the TCP stream\n/// through `encoder`.  In case the encoding fails with an error (this happens\n/// also in case we explicitly shutdown the connection), report the error to the\n/// dispatcher and quit.  If the dispatcher has already dropped the\n/// corresponding sender of `messages`, quit as well.\nfn encode_shannon_messages(\n    mut encoder: ShannonEncoder<TcpStream>,\n    messages: Receiver<ShannonMsg>,\n    dispatch: Sender<DispatchCmd>,\n) {\n    for msg in messages {\n        match encoder.encode(msg) {\n            Ok(_) => {\n                // Message encoded, continue.\n            }\n            Err(err) => {\n                let _ = dispatch.send(DispatchCmd::EncoderError(err));\n                break;\n            }\n        }\n    }\n}\n\nenum DispatchCmd {\n    MercuryReq {\n        request: MercuryRequest,\n        callback: Sender<MercuryResponse>,\n    },\n    AudioKeyReq {\n        track: ItemId,\n        file: FileId,\n        callback: Sender<Result<AudioKey, Error>>,\n    },\n    CountryCodeReq {\n        callback: Sender<Option<String>>,\n    },\n    DecodedMsg(ShannonMsg),\n    DecoderError(io::Error),\n    EncoderError(io::Error),\n    Shutdown,\n}\n\nfn dispatch_messages(\n    dispatch: Receiver<DispatchCmd>,\n    messages: Sender<ShannonMsg>,\n    stream: TcpStream,\n) {\n    let mut mercury = MercuryDispatcher::new();\n    let mut audio_key = AudioKeyDispatcher::new();\n    let mut country_code = None;\n\n    for disp in dispatch {\n        match disp {\n            DispatchCmd::MercuryReq { request, callback } => {\n                let msg = mercury.enqueue_request(request, callback);\n                let _ = messages.send(msg);\n            }\n            DispatchCmd::AudioKeyReq {\n                track,\n                file,\n                callback,\n            } => {\n                let msg = audio_key.enqueue_request(track, file, callback);\n                let _ = messages.send(msg);\n            }\n            DispatchCmd::CountryCodeReq { callback } => {\n                let _ = callback.send(country_code.clone());\n            }\n            DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::PING => {\n                let _ = messages.send(pong_message());\n            }\n            DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::COUNTRY_CODE => {\n                country_code.replace(parse_country_code(msg).unwrap());\n            }\n            DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::AES_KEY => {\n                audio_key.handle_aes_key(msg)\n            }\n            DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::AES_KEY_ERROR => {\n                audio_key.handle_aes_key_error(msg)\n            }\n            DispatchCmd::DecodedMsg(msg) if msg.cmd == ShannonMsg::MERCURY_REQ => {\n                mercury.handle_mercury_req(msg)\n            }\n            DispatchCmd::DecodedMsg(msg) => {\n                log::debug!(\"ignored message: {:?}\", msg.cmd);\n            }\n            DispatchCmd::DecoderError(err) => {\n                log::error!(\"connection error: {err:?}\");\n                let _ = stream.shutdown(Shutdown::Write);\n                break;\n            }\n            DispatchCmd::EncoderError(err) => {\n                log::error!(\"connection error: {err:?}\");\n                let _ = stream.shutdown(Shutdown::Read);\n                break;\n            }\n            DispatchCmd::Shutdown => {\n                log::info!(\"connection shutdown\");\n                let _ = stream.shutdown(Shutdown::Both);\n                break;\n            }\n        }\n    }\n}\n\nfn pong_message() -> ShannonMsg {\n    ShannonMsg::new(ShannonMsg::PONG, vec![0, 0, 0, 0])\n}\n\nfn parse_country_code(msg: ShannonMsg) -> Result<String, Error> {\n    String::from_utf8(msg.payload)\n        .ok()\n        .ok_or(Error::UnexpectedResponse)\n}\n\nimpl From<serde_json::Error> for Error {\n    fn from(error: serde_json::Error) -> Self {\n        Error::JsonError(Box::new(error))\n    }\n}\n"
  },
  {
    "path": "psst-core/src/session/token.rs",
    "content": "// Ported from librespot\n\nuse std::time::{Duration, Instant};\n\nconst EXPIRY_THRESHOLD: Duration = Duration::from_secs(10);\n\n#[derive(Clone, Debug)]\npub struct Token {\n    pub access_token: String,\n    pub expires_in: Duration,\n    pub token_type: String,\n    pub scopes: Vec<String>,\n    pub timestamp: Instant,\n}\n\nimpl Token {\n    pub fn is_expired(&self) -> bool {\n        self.timestamp + (self.expires_in.saturating_sub(EXPIRY_THRESHOLD)) < Instant::now()\n    }\n}"
  },
  {
    "path": "psst-core/src/system_info.rs",
    "content": "/// Operating System as given by the Rust standard library\npub const OS: &str = std::env::consts::OS;\n\n/// Device ID used for authentication procedures.\n/// librespot opts for UUIDv4s instead\npub const DEVICE_ID: &str = \"Psst\";\n\n/// Client ID for desktop keymaster client\npub const CLIENT_ID: &str = \"65b708073fc0480ea92a077233ca87bd\";\n\n/// The semantic version of the Spotify desktop client\npub const SPOTIFY_SEMANTIC_VERSION: &str = \"1.2.52.442\";"
  },
  {
    "path": "psst-core/src/util.rs",
    "content": "use crate::error::Error;\nuse byteorder::{BigEndian, ByteOrder};\nuse num_traits::{One, WrappingAdd};\nuse sha1::{Digest, Sha1};\nuse std::time::Instant;\nuse std::{io, io::SeekFrom, mem, time::Duration};\n\npub const NET_CONNECT_TIMEOUT: Duration = Duration::from_millis(8 * 1000);\n\npub const NET_IO_TIMEOUT: Duration = Duration::from_millis(16 * 1000);\n\npub fn default_ureq_agent_builder(\n    proxy_url: Option<&str>,\n) -> ureq::config::ConfigBuilder<ureq::typestate::AgentScope> {\n    let mut agent = ureq::Agent::config_builder()\n        .timeout_global(Some(Duration::from_secs(5)))\n        .timeout_connect(Some(NET_CONNECT_TIMEOUT))\n        .timeout_recv_response(Some(NET_IO_TIMEOUT))\n        .timeout_send_request(Some(NET_IO_TIMEOUT));\n\n    if let Some(proxy_url) = proxy_url {\n        let proxy = ureq::Proxy::new(proxy_url).ok();\n        agent = agent.proxy(proxy);\n    }\n\n    agent\n}\n\npub fn solve_hash_cash(\n    ctx: &[u8],\n    prefix: &[u8],\n    length: i32,\n    dst: &mut [u8],\n) -> Result<Duration, Error> {\n    const TIMEOUT: Duration = Duration::from_secs(5);\n    // SHA-1 produces a 20-byte hash, we check the trailing 8 bytes.\n    const OFFSET_LEN: usize = 8;\n    const CHECK_OFFSET: usize = 20 - OFFSET_LEN;\n\n    let now = Instant::now();\n    let initial_digest = Sha1::digest(ctx);\n    let target = BigEndian::read_i64(&initial_digest[CHECK_OFFSET..]);\n\n    let mut suffix = [0u8; 16];\n    let mut counter = 0i64;\n\n    while now.elapsed() < TIMEOUT {\n        suffix[..OFFSET_LEN].copy_from_slice(&target.wrapping_add(counter).to_be_bytes());\n        suffix[OFFSET_LEN..].copy_from_slice(&counter.to_be_bytes());\n\n        let final_digest = Sha1::new()\n            .chain_update(prefix)\n            .chain_update(suffix)\n            .finalize();\n\n        if BigEndian::read_i64(&final_digest[CHECK_OFFSET..]).trailing_zeros() >= (length as u32) {\n            dst.copy_from_slice(&suffix);\n            return Ok(now.elapsed());\n        }\n\n        counter += 1;\n    }\n\n    Err(Error::InvalidStateError(\n        format!(\"{TIMEOUT:?} expired\").into(),\n    ))\n}\n\n#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Default)]\npub struct Sequence<T>(T);\n\nimpl<T: One + WrappingAdd> Sequence<T> {\n    pub fn new(value: T) -> Self {\n        Sequence(value)\n    }\n\n    pub fn advance(&mut self) -> T {\n        let next = self.0.wrapping_add(&T::one());\n        mem::replace(&mut self.0, next)\n    }\n}\n\npub struct OffsetFile<T> {\n    stream: T,\n    offset: u64,\n}\n\nimpl<T: io::Seek> OffsetFile<T> {\n    pub fn new(mut stream: T, offset: u64) -> io::Result<OffsetFile<T>> {\n        stream.seek(SeekFrom::Start(offset))?;\n        Ok(OffsetFile { stream, offset })\n    }\n}\n\nimpl<T: io::Read> io::Read for OffsetFile<T> {\n    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n        self.stream.read(buf)\n    }\n}\n\nimpl<T: io::Write> io::Write for OffsetFile<T> {\n    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {\n        self.stream.write(buf)\n    }\n\n    fn flush(&mut self) -> io::Result<()> {\n        self.stream.flush()\n    }\n}\n\nimpl<T: io::Seek> io::Seek for OffsetFile<T> {\n    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {\n        let offset_pos = match pos {\n            SeekFrom::Start(offset) => SeekFrom::Start(offset + self.offset),\n            from_end_or_current => from_end_or_current,\n        };\n        let new_pos = self.stream.seek(offset_pos)?;\n        let offset_new_pos = new_pos.saturating_sub(self.offset);\n        Ok(offset_new_pos)\n    }\n}\n\npub struct FileWithConstSize<T> {\n    stream: T,\n    len: u64,\n}\n\nimpl<T> FileWithConstSize<T> {\n    pub fn len(&self) -> u64 {\n        self.len\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.len() == 0\n    }\n}\n\nimpl<T> FileWithConstSize<T>\nwhere\n    T: io::Seek,\n{\n    pub fn new(mut stream: T) -> Self {\n        stream.seek(SeekFrom::End(0)).unwrap();\n        let len = stream.stream_position().unwrap();\n        stream.seek(SeekFrom::Start(0)).unwrap();\n        Self { stream, len }\n    }\n}\n\nimpl<T> io::Read for FileWithConstSize<T>\nwhere\n    T: io::Read,\n{\n    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n        self.stream.read(buf)\n    }\n}\n\nimpl<T> io::Seek for FileWithConstSize<T>\nwhere\n    T: io::Seek,\n{\n    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {\n        self.stream.seek(pos)\n    }\n}\n"
  },
  {
    "path": "psst-gui/Cargo.toml",
    "content": "[package]\nname = \"psst-gui\"\nversion = \"0.1.0\"\nauthors = [\"Jan Pochyla <jpochyla@gmail.com>\"]\nedition = \"2021\"\nbuild = \"build.rs\"\ndescription = \"Fast and native Spotify client\"\nrepository = \"https://github.com/jpochyla/psst\"\n\n[features]\ndefault = [\"cpal\"]\ncpal = [\"psst-core/cpal\"]\ncubeb = [\"psst-core/cubeb\"]\n\n[dependencies]\npsst-core = { path = \"../psst-core\" }\n\n# Common\ncrossbeam-channel = { version = \"0.5.15\" }\ndirectories = \"6.0.0\"\nenv_logger = { version = \"0.11.8\" }\nitertools = \"0.14.0\"\nlog = { version = \"0.4.27\" }\nlru = \"0.14.0\"\nparking_lot = { version = \"0.12.3\" }\nplatform-dirs = { version = \"0.3.0\" }\nrand = { version = \"0.9.1\" }\nregex = { version = \"1.11.1\" }\nserde = { version = \"1.0.219\", features = [\"derive\", \"rc\"] }\nserde_json = { version = \"1.0.140\" }\nthreadpool = { version = \"1.8.1\" }\ntime = { version = \"0.3.41\", features = [\"macros\", \"formatting\"] }\ntime-humanize = { version = \"0.1.3\" }\nureq = { version = \"3.0.11\", features = [\"json\", \"socks-proxy\"] }\nurl = { version = \"2.5.4\" }\ninfer = \"0.19.0\"\nurlencoding = { version = \"2.1.3\" }\n\n# GUI\ndruid = { git = \"https://github.com/jpochyla/druid\", branch = \"psst\", features = [\n  \"im\",\n  \"image\",\n  \"jpeg\",\n  \"png\",\n  \"webp\",\n  \"serde\",\n] }\ndruid-enums = { git = \"https://github.com/jpochyla/druid-enums\" }\ndruid-shell = { git = \"https://github.com/jpochyla/druid\", branch = \"psst\", features = [\n  \"raw-win-handle\",\n] }\nopen = { version = \"5.3.2\" }\nraw-window-handle = \"0.5.2\" # Must stay compatible with Druid\nsouvlaki = { version = \"0.8.2\", default-features = false, features = [\"use_zbus\"] }\nsanitize_html = \"0.9.0\"\nrustfm-scrobble = \"1.1.1\"\n[target.'cfg(windows)'.build-dependencies]\nwinres = { version = \"0.1.12\" }\nimage = { version = \"0.25.6\" }\n\n[package.metadata.bundle]\nname = \"Psst\"\nidentifier = \"com.jpochyla.psst\"\nicon = [\"assets/logo.icns\"]\nversion = \"0.1.0\"\nosx_minimum_system_version = \"11.0\"\nresources = []\ncopyright = \"Copyright (c) Jan Pochyla 2024. All rights reserved.\"\ncategory = \"Music\"\nshort_description = \"Fast and native Spotify client\"\nlong_description = \"\"\"\nSmall and efficient graphical music player for the Spotify network.\n\"\"\"\n"
  },
  {
    "path": "psst-gui/build-icons.sh",
    "content": "#!/bin/bash\nset -euo pipefail\n\n# Check for required tools\ncommand -v rsvg-convert >/dev/null 2>&1 || {\n\techo >&2 \"rsvg-convert is required but not installed. Aborting.\"\n\texit 1\n}\ncommand -v iconutil >/dev/null 2>&1 || {\n\techo >&2 \"iconutil is required but not installed. Aborting.\"\n\texit 1\n}\ncommand -v pngquant >/dev/null 2>&1 || {\n\techo >&2 \"pngquant is required but not installed. Aborting.\"\n\texit 1\n}\ncommand -v optipng >/dev/null 2>&1 || {\n\techo >&2 \"optipng is required but not installed. Aborting.\"\n\texit 1\n}\n\n# Temp folder\nICON_DIR=\"icons\"\nmkdir -p \"$ICON_DIR\"\n\n# Generate PNG icons from SVG\nSIZES=(16 32 64 128 256 512)\nfor size in \"${SIZES[@]}\"; do\n\trsvg-convert -w $size -h $size assets/logo.svg -o \"$ICON_DIR/logo_${size}.png\"\n\n\t# Apply lossy compression with pngquant\n\tpngquant --force --quality=60-80 \"$ICON_DIR/logo_${size}.png\" --output \"$ICON_DIR/logo_${size}.png\"\n\n\t# Further optimize with optipng\n\toptipng -quiet -o5 \"$ICON_DIR/logo_${size}.png\"\n\n\t# For smaller sizes, reduce color depth\n\tif [ $size -le 32 ]; then\n\t\tmagick \"$ICON_DIR/logo_${size}.png\" -colors 256 PNG8:\"$ICON_DIR/logo_${size}.png\"\n\tfi\ndone\n\n# Generate ICNS for macOS\nICONSET_DIR=\"$ICON_DIR/psst.iconset\"\nmkdir -p \"$ICONSET_DIR\"\nfor size in \"${SIZES[@]}\"; do\n\tcp \"$ICON_DIR/logo_${size}.png\" \"$ICONSET_DIR/icon_${size}x${size}.png\"\n\tif [ $size -ne 16 ] && [ $size -ne 32 ]; then\n\t\tcp \"$ICON_DIR/logo_${size}.png\" \"$ICONSET_DIR/icon_$((size / 2))x$((size / 2))@2x.png\"\n\tfi\ndone\n\n# Create ICNS file\niconutil -c icns \"$ICONSET_DIR\" -o assets/logo.icns\n\n# Cleanup\nrm -r \"$ICON_DIR\"\n\necho \"Icon generation complete. ICNS file size: $(du -h assets/logo.icns | cut -f1)\"\n"
  },
  {
    "path": "psst-gui/build.rs",
    "content": "fn main() {\n    #[cfg(windows)]\n    add_windows_icon();\n}\n\n#[cfg(windows)]\nfn add_windows_icon() {\n    use image::{\n        codecs::ico::{IcoEncoder, IcoFrame},\n        ColorType,\n    };\n\n    let ico_path = \"assets/logo.ico\";\n    if std::fs::metadata(ico_path).is_err() {\n        let ico_frames = load_images();\n        save_ico(&ico_frames, ico_path);\n    }\n\n    let mut res = winres::WindowsResource::new();\n    res.set_icon(ico_path);\n    res.compile().expect(\"Could not attach exe icon\");\n\n    fn load_images() -> Vec<IcoFrame<'static>> {\n        let sizes = [32, 64, 128, 256];\n        sizes\n            .iter()\n            .map(|s| {\n                IcoFrame::as_png(\n                    image::open(format!(\"assets/logo_{s}.png\"))\n                        .unwrap()\n                        .as_bytes(),\n                    *s,\n                    *s,\n                    ColorType::Rgba8.into(),\n                )\n                .unwrap()\n            })\n            .collect()\n    }\n\n    fn save_ico(images: &[IcoFrame<'_>], ico_path: &str) {\n        let file = std::fs::File::create(ico_path).unwrap();\n        let encoder = IcoEncoder::new(file);\n        encoder.encode_images(images).unwrap();\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/cmd.rs",
    "content": "use crate::data::Track;\nuse druid::{Selector, WidgetId};\nuse psst_core::{item_id::ItemId, player::item::PlaybackItem};\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse crate::{\n    data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry},\n    ui::find::Find,\n};\n\n// Widget IDs\npub const WIDGET_SEARCH_INPUT: WidgetId = WidgetId::reserved(1);\n\n// Common\npub const SHOW_MAIN: Selector = Selector::new(\"app.show-main\");\npub const SHOW_ACCOUNT_SETUP: Selector = Selector::new(\"app.show-initial\");\npub const CLOSE_ALL_WINDOWS: Selector = Selector::new(\"app.close-all-windows\");\npub const QUIT_APP_WITH_SAVE: Selector = Selector::new(\"app.quit-with-save\");\npub const SET_FOCUS: Selector = Selector::new(\"app.set-focus\");\npub const COPY: Selector<String> = Selector::new(\"app.copy-to-clipboard\");\npub const GO_TO_URL: Selector<String> = Selector::new(\"app.go-to-url\");\n\n// Find\npub const TOGGLE_FINDER: Selector = Selector::new(\"app.show-finder\");\npub const FIND_IN_PLAYLIST: Selector<Find> = Selector::new(\"find-in-playlist\");\npub const FIND_IN_SAVED_TRACKS: Selector<Find> = Selector::new(\"find-in-saved-tracks\");\n\n// Session\npub const SESSION_CONNECT: Selector = Selector::new(\"app.session-connect\");\npub const LOG_OUT: Selector = Selector::new(\"app.log-out\");\n\n// Navigation\npub const NAVIGATE: Selector<Nav> = Selector::new(\"app.navigates\");\npub const NAVIGATE_BACK: Selector<usize> = Selector::new(\"app.navigate-back\");\npub const NAVIGATE_REFRESH: Selector = Selector::new(\"app.navigate-refresh\");\npub const TOGGLE_LYRICS: Selector = Selector::new(\"app.toggle-lyrics\");\n\n// Playback state\npub const PLAYBACK_LOADING: Selector<ItemId> = Selector::new(\"app.playback-loading\");\npub const PLAYBACK_PLAYING: Selector<(ItemId, Duration)> = Selector::new(\"app.playback-playing\");\npub const PLAYBACK_PROGRESS: Selector<Duration> = Selector::new(\"app.playback-progress\");\npub const PLAYBACK_PAUSING: Selector = Selector::new(\"app.playback-pausing\");\npub const PLAYBACK_RESUMING: Selector = Selector::new(\"app.playback-resuming\");\npub const PLAYBACK_BLOCKED: Selector = Selector::new(\"app.playback-blocked\");\npub const PLAYBACK_STOPPED: Selector = Selector::new(\"app.playback-stopped\");\n\n// Playback control\npub const PLAY: Selector<usize> = Selector::new(\"app.play-index\");\npub const PLAY_TRACKS: Selector<PlaybackPayload> = Selector::new(\"app.play-tracks\");\npub const PLAY_PREVIOUS: Selector = Selector::new(\"app.play-previous\");\npub const PLAY_PAUSE: Selector = Selector::new(\"app.play-pause\");\npub const PLAY_RESUME: Selector = Selector::new(\"app.play-resume\");\npub const PLAY_NEXT: Selector = Selector::new(\"app.play-next\");\npub const PLAY_STOP: Selector = Selector::new(\"app.play-stop\");\npub const ADD_TO_QUEUE: Selector<(QueueEntry, PlaybackItem)> = Selector::new(\"app.add-to-queue\");\npub const PLAY_QUEUE_BEHAVIOR: Selector<QueueBehavior> = Selector::new(\"app.play-queue-behavior\");\npub const PLAY_SEEK: Selector<f64> = Selector::new(\"app.play-seek\");\npub const SKIP_TO_POSITION: Selector<u64> = Selector::new(\"app.skip-to-position\");\n\n// Sorting control\npub const SORT_BY_DATE_ADDED: Selector = Selector::new(\"app.sort-by-date-added\");\npub const SORT_BY_TITLE: Selector = Selector::new(\"app.sort-by-title\");\npub const SORT_BY_ARTIST: Selector = Selector::new(\"app.sort-by-artist\");\npub const SORT_BY_ALBUM: Selector = Selector::new(\"app.sort-by-album\");\npub const SORT_BY_DURATION: Selector = Selector::new(\"app.sort-by-duration\");\n\n// Sort direction control\npub const TOGGLE_SORT_ORDER: Selector = Selector::new(\"app.toggle-sort-order\");\n\n// Track credits\npub const SHOW_CREDITS_WINDOW: Selector<Arc<Track>> = Selector::new(\"app.credits-show-window\");\npub const LOAD_TRACK_CREDITS: Selector<Arc<Track>> = Selector::new(\"app.credits-load\");\n\n// Artwork\npub const SHOW_ARTWORK: Selector = Selector::new(\"app.show-artwork\");\n"
  },
  {
    "path": "psst-gui/src/controller/after_delay.rs",
    "content": "use std::time::Duration;\n\nuse druid::{\n    widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, TimerToken, Widget,\n};\n\ntype DelayFunc<T> = Box<dyn FnOnce(&mut EventCtx, &mut T, &Env)>;\n\npub struct AfterDelay<T> {\n    duration: Duration,\n    timer: TimerToken,\n    func: Option<DelayFunc<T>>,\n}\n\nimpl<T> AfterDelay<T> {\n    pub fn new(\n        duration: Duration,\n        func: impl FnOnce(&mut EventCtx, &mut T, &Env) + 'static,\n    ) -> Self {\n        Self {\n            duration,\n            timer: TimerToken::INVALID,\n            func: Some(Box::new(func)),\n        }\n    }\n}\n\nimpl<T, W> Controller<T, W> for AfterDelay<T>\nwhere\n    T: Data,\n    W: Widget<T>,\n{\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::Timer(token) if token == &self.timer => {\n                if let Some(func) = self.func.take() {\n                    func(ctx, data, env);\n                }\n                self.timer = TimerToken::INVALID;\n            }\n            _ => child.event(ctx, event, data, env),\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &T,\n        env: &Env,\n    ) {\n        if let LifeCycle::WidgetAdded = event {\n            self.timer = ctx.request_timer(self.duration);\n        }\n        child.lifecycle(ctx, event, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/alert_cleanup.rs",
    "content": "use crate::data::AppState;\nuse druid::{widget::Controller, Env, Event, EventCtx, Widget};\nuse std::time::Duration;\n\npub struct AlertCleanupController;\n\nconst CLEANUP_INTERVAL: Duration = Duration::from_secs(1);\n\nimpl<W: Widget<AppState>> Controller<AppState, W> for AlertCleanupController {\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::WindowConnected => {\n                ctx.request_timer(CLEANUP_INTERVAL);\n            }\n            Event::Timer(_) => {\n                data.cleanup_alerts();\n                ctx.request_timer(CLEANUP_INTERVAL);\n            }\n            _ => {}\n        }\n        child.event(ctx, event, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/ex_click.rs",
    "content": "use druid::{\n    widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, MouseButton,\n    MouseEvent, Widget,\n};\n\npub struct ExClick<T> {\n    button: Option<MouseButton>,\n    action: Box<dyn Fn(&mut EventCtx, &MouseEvent, &mut T, &Env)>,\n}\n\nimpl<T: Data> ExClick<T> {\n    pub fn new(\n        button: Option<MouseButton>,\n        action: impl Fn(&mut EventCtx, &MouseEvent, &mut T, &Env) + 'static,\n    ) -> Self {\n        ExClick {\n            button,\n            action: Box::new(action),\n        }\n    }\n}\n\nimpl<T: Data, W: Widget<T>> Controller<T, W> for ExClick<T> {\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::MouseDown(mouse_event) => {\n                if mouse_event.button == self.button.unwrap_or(mouse_event.button) {\n                    ctx.set_active(true);\n                    ctx.request_paint();\n                }\n            }\n            Event::MouseUp(mouse_event) => {\n                if mouse_event.button == self.button.unwrap_or(mouse_event.button)\n                    && ctx.is_active()\n                {\n                    ctx.set_active(false);\n                    if ctx.is_hot() {\n                        (self.action)(ctx, mouse_event, data, env);\n                    }\n                    ctx.request_paint();\n                }\n            }\n            _ => {}\n        }\n\n        child.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &T,\n        env: &Env,\n    ) {\n        if let LifeCycle::HotChanged(_) | LifeCycle::FocusChanged(_) = event {\n            ctx.request_paint();\n        }\n\n        child.lifecycle(ctx, event, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/ex_cursor.rs",
    "content": "use std::marker::PhantomData;\n\nuse druid::{widget::Controller, Data, Env, Event, EventCtx, Widget};\nuse druid_shell::Cursor;\n\npub struct ExCursor<T> {\n    cursor: Cursor,\n    phantom: PhantomData<T>,\n}\n\nimpl<T: Data> ExCursor<T> {\n    pub fn new(cursor: Cursor) -> Self {\n        Self {\n            cursor,\n            phantom: PhantomData,\n        }\n    }\n}\n\nimpl<T: Data, W: Widget<T>> Controller<T, W> for ExCursor<T> {\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        if let Event::MouseMove(_) = event {\n            ctx.set_cursor(&self.cursor);\n        }\n\n        child.event(ctx, event, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/ex_scroll.rs",
    "content": "use crate::data::SliderScrollScale;\nuse druid::{widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, Widget};\n\npub struct ExScroll<T> {\n    scale_picker: Box<dyn Fn(&mut T) -> &SliderScrollScale>,\n    action: Box<dyn Fn(&mut EventCtx, &mut T, &Env, f64)>,\n}\n\nimpl<T: Data> ExScroll<T> {\n    pub fn new(\n        scale_picker: impl Fn(&mut T) -> &SliderScrollScale + 'static,\n        action: impl Fn(&mut EventCtx, &mut T, &Env, f64) + 'static,\n    ) -> Self {\n        ExScroll {\n            scale_picker: Box::new(scale_picker),\n            action: Box::new(action),\n        }\n    }\n}\n\nimpl<T: Data, W: Widget<T>> Controller<T, W> for ExScroll<T> {\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        if let Event::Wheel(mouse_event) = event {\n            ctx.set_active(true);\n\n            let delta = mouse_event.wheel_delta;\n            let scale_config = (self.scale_picker)(data);\n            let scale = scale_config.scale / 100.;\n\n            let (directional_scale, delta) = if delta.x == 0. {\n                (scale_config.y, -delta.y)\n            } else {\n                (scale_config.x, delta.x)\n            };\n            let scaled_delta = delta.signum() * scale * 1. / directional_scale;\n            (self.action)(ctx, data, env, scaled_delta);\n\n            ctx.set_active(false);\n            ctx.request_paint()\n        }\n\n        child.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &T,\n        env: &Env,\n    ) {\n        if let LifeCycle::HotChanged(_) | LifeCycle::FocusChanged(_) = event {\n            ctx.request_paint();\n        }\n\n        child.lifecycle(ctx, event, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/input.rs",
    "content": "use druid::{\n    commands,\n    widget::{prelude::*, Controller, TextBox},\n    HotKey, KbKey, SysMods,\n};\n\nuse crate::cmd;\n\ntype SubmitHandler = Box<dyn Fn(&mut EventCtx, &mut String, &Env)>;\n\npub struct InputController {\n    on_submit: Option<SubmitHandler>,\n}\n\nimpl InputController {\n    pub fn new() -> Self {\n        Self { on_submit: None }\n    }\n\n    pub fn on_submit(\n        mut self,\n        on_submit: impl Fn(&mut EventCtx, &mut String, &Env) + 'static,\n    ) -> Self {\n        self.on_submit = Some(Box::new(on_submit));\n        self\n    }\n}\n\nimpl Controller<String, TextBox<String>> for InputController {\n    fn event(\n        &mut self,\n        child: &mut TextBox<String>,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut String,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(cmd::SET_FOCUS) => {\n                ctx.request_focus();\n                ctx.request_paint();\n                ctx.set_handled();\n            }\n            Event::KeyDown(k_e) if HotKey::new(None, KbKey::Enter).matches(k_e) => {\n                ctx.resign_focus();\n                ctx.request_paint();\n                ctx.set_handled();\n                if let Some(on_submit) = &self.on_submit {\n                    on_submit(ctx, data, env);\n                }\n            }\n            Event::KeyDown(k_e) if k_e.key == KbKey::Escape => {\n                ctx.resign_focus();\n                ctx.set_handled();\n            }\n            Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, \"c\").matches(k_e) => {\n                ctx.submit_command(commands::COPY);\n                ctx.set_handled();\n            }\n            Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, \"x\").matches(k_e) => {\n                ctx.submit_command(commands::CUT);\n                ctx.set_handled();\n            }\n            Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, \"v\").matches(k_e) => {\n                ctx.submit_command(commands::PASTE);\n                ctx.set_handled();\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/mod.rs",
    "content": "mod after_delay;\nmod alert_cleanup;\nmod ex_click;\nmod ex_cursor;\nmod ex_scroll;\nmod input;\nmod nav;\nmod on_command;\nmod on_command_async;\nmod on_debounce;\nmod on_update;\nmod playback;\nmod session;\nmod sort;\n\npub use after_delay::AfterDelay;\npub use alert_cleanup::AlertCleanupController;\npub use ex_click::ExClick;\npub use ex_cursor::ExCursor;\npub use ex_scroll::ExScroll;\npub use input::InputController;\npub use nav::NavController;\npub use on_command::OnCommand;\npub use on_command_async::OnCommandAsync;\npub use on_debounce::OnDebounce;\npub use on_update::OnUpdate;\npub use playback::PlaybackController;\npub use session::SessionController;\npub use sort::SortController;\n"
  },
  {
    "path": "psst-gui/src/controller/nav.rs",
    "content": "use crate::{\n    cmd,\n    data::{AppState, Nav, SpotifyUrl},\n    ui::{album, artist, library, lyrics, playlist, recommend, search, show},\n};\nuse druid::widget::{prelude::*, Controller};\nuse druid::Code;\n\npub struct NavController;\n\nimpl NavController {\n    fn load_route_data(&self, ctx: &mut EventCtx, data: &mut AppState) {\n        match &data.nav {\n            Nav::Home => {}\n            Nav::Lyrics => {}\n            Nav::SavedTracks => {\n                if !data.library.saved_tracks.is_resolved() {\n                    ctx.submit_command(library::LOAD_TRACKS);\n                }\n            }\n            Nav::SavedAlbums => {\n                if !data.library.saved_albums.is_resolved() {\n                    ctx.submit_command(library::LOAD_ALBUMS);\n                }\n            }\n            Nav::Shows => {\n                if !data.library.saved_shows.is_resolved() {\n                    ctx.submit_command(library::LOAD_SHOWS);\n                }\n            }\n            Nav::SearchResults(query) => {\n                if let Some(link) = SpotifyUrl::parse(query) {\n                    ctx.submit_command(search::OPEN_LINK.with(link));\n                } else if !data\n                    .search\n                    .results\n                    .contains(&(query.clone(), data.search.topic))\n                {\n                    ctx.submit_command(\n                        search::LOAD_RESULTS.with((query.to_owned(), data.search.topic)),\n                    );\n                }\n            }\n            Nav::AlbumDetail(link, _) => {\n                if !data.album_detail.album.contains(link) {\n                    ctx.submit_command(album::LOAD_DETAIL.with(link.to_owned()));\n                }\n            }\n            Nav::ArtistDetail(link) => {\n                if !data.artist_detail.top_tracks.contains(link) {\n                    ctx.submit_command(artist::LOAD_DETAIL.with(link.to_owned()));\n                }\n            }\n            Nav::PlaylistDetail(link) => {\n                if !data.playlist_detail.playlist.contains(link) {\n                    ctx.submit_command(\n                        playlist::LOAD_DETAIL.with((link.to_owned(), data.to_owned())),\n                    );\n                }\n            }\n            Nav::ShowDetail(link) => {\n                if !data.show_detail.show.contains(link) {\n                    ctx.submit_command(show::LOAD_DETAIL.with(link.to_owned()));\n                }\n            }\n            Nav::Recommendations(request) => {\n                if !data.recommend.results.contains(request) {\n                    ctx.submit_command(recommend::LOAD_RESULTS.with(request.clone()));\n                }\n            }\n        }\n    }\n}\n\nimpl<W> Controller<AppState, W> for NavController\nwhere\n    W: Widget<AppState>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(cmd::NAVIGATE) => {\n                let nav = cmd.get_unchecked(cmd::NAVIGATE);\n                data.navigate(nav);\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            Event::Command(cmd) if cmd.is(cmd::NAVIGATE_BACK) => {\n                let count = cmd.get_unchecked(cmd::NAVIGATE_BACK);\n                for _ in 0..*count {\n                    data.navigate_back();\n                }\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            Event::Command(cmd) if cmd.is(cmd::NAVIGATE_REFRESH) => {\n                data.refresh_playlist();\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            Event::Command(cmd) if cmd.is(cmd::TOGGLE_LYRICS) => {\n                match data.nav {\n                    Nav::Lyrics => data.navigate_back(),\n                    _ => {\n                        data.navigate(&Nav::Lyrics);\n                        if let Some(np) = data.playback.now_playing.as_ref() {\n                            ctx.submit_command(lyrics::SHOW_LYRICS.with(np.clone()));\n                        }\n                    }\n                }\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            Event::MouseDown(cmd) if cmd.button.is_x1() => {\n                data.navigate_back();\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            Event::KeyDown(key) if key.mods.ctrl() && key.code == Code::KeyR => {\n                data.refresh_all();\n                ctx.set_handled();\n                self.load_route_data(ctx, data);\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &AppState,\n        env: &Env,\n    ) {\n        if let LifeCycle::WidgetAdded = event {\n            // Loads the library's saved tracks without the user needing to click on the tab.\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::SavedTracks));\n            // Load the last route, or the default.\n            ctx.submit_command(\n                cmd::NAVIGATE.with(data.config.last_route.to_owned().unwrap_or_default()),\n            );\n        }\n        child.lifecycle(ctx, event, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/on_command.rs",
    "content": "use druid::{widget::Controller, Data, Env, Event, EventCtx, Selector, Widget};\n\npub struct OnCommand<U, F> {\n    selector: Selector<U>,\n    handler: F,\n}\n\nimpl<U, F> OnCommand<U, F> {\n    pub fn new<T>(selector: Selector<U>, handler: F) -> Self\n    where\n        F: Fn(&mut EventCtx, &U, &mut T),\n    {\n        Self { selector, handler }\n    }\n}\n\nimpl<T, U, F, W> Controller<T, W> for OnCommand<U, F>\nwhere\n    T: Data,\n    U: 'static,\n    F: Fn(&mut EventCtx, &U, &mut T),\n    W: Widget<T>,\n{\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::Command(cmd) if cmd.is(self.selector) => {\n                (self.handler)(ctx, cmd.get_unchecked(self.selector), data);\n            }\n            _ => {}\n        }\n        child.event(ctx, event, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/on_command_async.rs",
    "content": "use std::{\n    sync::Arc,\n    thread::{self, JoinHandle},\n};\n\nuse druid::{\n    BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx,\n    Selector, SingleUse, Size, Target, UpdateCtx, Widget, WidgetPod,\n};\n\ntype AsyncCmdPre<T, U> = Box<dyn Fn(&mut EventCtx, &mut T, U)>;\ntype AsyncCmdReq<U, V> = Arc<dyn Fn(U) -> V + Sync + Send + 'static>;\ntype AsyncCmdRes<T, U, V> = Box<dyn Fn(&mut EventCtx, &mut T, (U, V))>;\n\npub struct OnCommandAsync<W, T, U, V> {\n    child: WidgetPod<T, W>,\n    selector: Selector<U>,\n    preflight_fn: AsyncCmdPre<T, U>,\n    request_fn: AsyncCmdReq<U, V>,\n    response_fn: AsyncCmdRes<T, U, V>,\n    thread: Option<JoinHandle<()>>,\n}\n\nimpl<W, T, U, V> OnCommandAsync<W, T, U, V>\nwhere\n    W: Widget<T>,\n{\n    const RESPONSE: Selector<SingleUse<(U, V)>> = Selector::new(\"on_cmd_async.response\");\n\n    pub fn new(\n        child: W,\n        selector: Selector<U>,\n        preflight_fn: AsyncCmdPre<T, U>,\n        request_fn: AsyncCmdReq<U, V>,\n        response_fn: AsyncCmdRes<T, U, V>,\n    ) -> Self {\n        Self {\n            child: WidgetPod::new(child),\n            selector,\n            preflight_fn,\n            request_fn,\n            response_fn,\n            thread: None,\n        }\n    }\n}\n\nimpl<W, T, U, V> Widget<T> for OnCommandAsync<W, T, U, V>\nwhere\n    W: Widget<T>,\n    T: Data,\n    U: Send + Clone + 'static,\n    V: Send + 'static,\n{\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::Command(cmd) if cmd.is(self.selector) => {\n                let req = cmd.get_unchecked(self.selector);\n\n                (self.preflight_fn)(ctx, data, req.to_owned());\n\n                let old_thread = self.thread.replace(thread::spawn({\n                    let req_fn = self.request_fn.clone();\n                    let req = req.to_owned();\n                    let sink = ctx.get_external_handle();\n                    let self_id = ctx.widget_id();\n\n                    move || {\n                        let res = req_fn(req.clone());\n                        sink.submit_command(\n                            Self::RESPONSE,\n                            SingleUse::new((req, res)),\n                            Target::Widget(self_id),\n                        )\n                        .unwrap();\n                    }\n                }));\n                if old_thread.is_some() {\n                    log::warn!(\"async action pending\");\n                }\n            }\n            Event::Command(cmd) if cmd.is(Self::RESPONSE) => {\n                let res = cmd.get_unchecked(Self::RESPONSE).take().unwrap();\n                (self.response_fn)(ctx, data, res);\n                self.thread.take();\n                ctx.set_handled();\n            }\n            _ => {\n                self.child.event(ctx, event, data, env);\n            }\n        }\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.child.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        self.child.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        self.child.layout(ctx, bc, data, env)\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        self.child.paint(ctx, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/on_debounce.rs",
    "content": "use std::time::Duration;\n\nuse druid::{widget::Controller, Data, Env, Event, EventCtx, TimerToken, UpdateCtx, Widget};\n\npub struct OnDebounce<T> {\n    duration: Duration,\n    timer: TimerToken,\n    handler: Box<dyn Fn(&mut EventCtx, &mut T, &Env)>,\n}\n\nimpl<T> OnDebounce<T> {\n    pub fn trailing(\n        duration: Duration,\n        handler: impl Fn(&mut EventCtx, &mut T, &Env) + 'static,\n    ) -> Self {\n        Self {\n            duration,\n            timer: TimerToken::INVALID,\n            handler: Box::new(handler),\n        }\n    }\n}\n\nimpl<T, W> Controller<T, W> for OnDebounce<T>\nwhere\n    T: Data,\n    W: Widget<T>,\n{\n    fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::Timer(token) if token == &self.timer => {\n                (self.handler)(ctx, data, env);\n                self.timer = TimerToken::INVALID;\n                ctx.set_handled();\n            }\n            _ => child.event(ctx, event, data, env),\n        }\n    }\n\n    fn update(&mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        if !old_data.same(data) {\n            self.timer = ctx.request_timer(self.duration);\n        }\n        child.update(ctx, old_data, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/on_update.rs",
    "content": "use druid::{widget::Controller, Data, Env, UpdateCtx, Widget};\n\npub struct OnUpdate<F> {\n    handler: F,\n}\n\nimpl<F> OnUpdate<F> {\n    pub fn new<T>(handler: F) -> Self\n    where\n        F: Fn(&mut UpdateCtx, &T, &T, &Env),\n    {\n        Self { handler }\n    }\n}\n\nimpl<T, F, W> Controller<T, W> for OnUpdate<F>\nwhere\n    T: Data,\n    F: Fn(&mut UpdateCtx, &T, &T, &Env),\n    W: Widget<T>,\n{\n    fn update(&mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        (self.handler)(ctx, old_data, data, env);\n        child.update(ctx, old_data, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/playback.rs",
    "content": "use std::{\n    thread::{self, JoinHandle},\n    time::Duration,\n};\n\nuse crossbeam_channel::Sender;\nuse druid::{\n    im::Vector,\n    widget::{prelude::*, Controller},\n    Code, ExtEventSink, InternalLifeCycle, KbKey, WindowHandle,\n};\nuse psst_core::{\n    audio::{normalize::NormalizationLevel, output::DefaultAudioOutput},\n    cache::Cache,\n    cdn::Cdn,\n    lastfm::LastFmClient,\n    player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent},\n    session::SessionService,\n};\nuse rustfm_scrobble::Scrobbler;\nuse souvlaki::{\n    MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,\n};\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse crate::{\n    cmd,\n    data::Nav,\n    data::{\n        AppState, Config, NowPlaying, Playable, Playback, PlaybackOrigin, PlaybackState,\n        QueueBehavior, QueueEntry,\n    },\n    ui::lyrics,\n};\n\npub struct PlaybackController {\n    sender: Option<Sender<PlayerEvent>>,\n    thread: Option<JoinHandle<()>>,\n    output: Option<DefaultAudioOutput>,\n    media_controls: Option<MediaControls>,\n    has_scrobbled: bool,\n    scrobbler: Option<Scrobbler>,\n    startup: bool,\n}\nfn init_scrobbler_instance(data: &AppState) -> Option<Scrobbler> {\n    if data.config.lastfm_enable {\n        if let (Some(api_key), Some(api_secret), Some(session_key)) = (\n            data.config.lastfm_api_key.as_deref(),\n            data.config.lastfm_api_secret.as_deref(),\n            data.config.lastfm_session_key.as_deref(),\n        ) {\n            match LastFmClient::create_scrobbler(Some(api_key), Some(api_secret), Some(session_key))\n            {\n                Ok(scr) => {\n                    log::info!(\"Last.fm Scrobbler instance created/updated.\");\n                    return Some(scr);\n                }\n                Err(e) => {\n                    log::warn!(\"Failed to create/update Last.fm Scrobbler instance: {e}\");\n                }\n            }\n        } else {\n            log::info!(\"Last.fm credentials incomplete or removed, clearing Scrobbler instance.\");\n        }\n    } else {\n        log::info!(\"Last.fm scrobbling is disabled, clearing Scrobbler instance.\");\n    }\n    None\n}\n\nimpl PlaybackController {\n    pub fn new() -> Self {\n        Self {\n            sender: None,\n            thread: None,\n            output: None,\n            media_controls: None,\n            has_scrobbled: false,\n            scrobbler: None,\n            startup: true,\n        }\n    }\n\n    fn open_audio_output_and_start_threads(\n        &mut self,\n        session: SessionService,\n        config: PlaybackConfig,\n        event_sink: ExtEventSink,\n        widget_id: WidgetId,\n        #[allow(unused_variables)] window: &WindowHandle,\n    ) {\n        let output = DefaultAudioOutput::open().unwrap();\n        let cache_dir = Config::cache_dir().unwrap();\n        let proxy_url = Config::proxy();\n        let player = Player::new(\n            session.clone(),\n            Cdn::new(session, proxy_url.as_deref()).unwrap(),\n            Cache::new(cache_dir).unwrap(),\n            config,\n            &output,\n        );\n\n        self.media_controls = Self::create_media_controls(player.sender(), window)\n            .map_err(|err| log::error!(\"failed to connect to media control interface: {err:?}\"))\n            .ok();\n\n        self.sender = Some(player.sender());\n        self.thread = Some(thread::spawn(move || {\n            Self::service_events(player, event_sink, widget_id);\n        }));\n        self.output.replace(output);\n    }\n\n    fn service_events(mut player: Player, event_sink: ExtEventSink, widget_id: WidgetId) {\n        for event in player.receiver() {\n            // Forward events that affect the UI state to the UI thread.\n            match &event {\n                PlayerEvent::Loading { item } => {\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_LOADING, item.item_id, widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Playing { path, position } => {\n                    let progress = position.to_owned();\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_PLAYING, (path.item_id, progress), widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Pausing { .. } => {\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_PAUSING, (), widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Resuming { .. } => {\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_RESUMING, (), widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Position { position, .. } => {\n                    let progress = position.to_owned();\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_PROGRESS, progress, widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Blocked { .. } => {\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_BLOCKED, (), widget_id)\n                        .unwrap();\n                }\n                PlayerEvent::Stopped => {\n                    event_sink\n                        .submit_command(cmd::PLAYBACK_STOPPED, (), widget_id)\n                        .unwrap();\n                }\n                _ => {}\n            }\n\n            // Let the player react to its internal events.\n            player.handle(event);\n        }\n    }\n\n    fn create_media_controls(\n        sender: Sender<PlayerEvent>,\n        #[allow(unused_variables)] window: &WindowHandle,\n    ) -> Result<MediaControls, souvlaki::Error> {\n        let hwnd = {\n            #[cfg(target_os = \"windows\")]\n            {\n                use raw_window_handle::{HasRawWindowHandle, RawWindowHandle};\n                let handle = match window.raw_window_handle() {\n                    RawWindowHandle::Win32(h) => h,\n                    _ => unreachable!(),\n                };\n                Some(handle.hwnd)\n            }\n            #[cfg(not(target_os = \"windows\"))]\n            None\n        };\n\n        let mut media_controls = MediaControls::new(PlatformConfig {\n            dbus_name: format!(\"com.jpochyla.psst.{}\", random_lowercase_string(8)).as_str(),\n            display_name: \"Psst\",\n            hwnd,\n        })?;\n\n        media_controls.attach(move |event| {\n            Self::handle_media_control_event(event, &sender);\n        })?;\n\n        Ok(media_controls)\n    }\n\n    fn handle_media_control_event(event: MediaControlEvent, sender: &Sender<PlayerEvent>) {\n        let cmd = match event {\n            MediaControlEvent::Play => PlayerEvent::Command(PlayerCommand::Resume),\n            MediaControlEvent::Pause => PlayerEvent::Command(PlayerCommand::Pause),\n            MediaControlEvent::Toggle => PlayerEvent::Command(PlayerCommand::PauseOrResume),\n            MediaControlEvent::Next => PlayerEvent::Command(PlayerCommand::Next),\n            MediaControlEvent::Previous => PlayerEvent::Command(PlayerCommand::Previous),\n            MediaControlEvent::SetPosition(MediaPosition(duration)) => {\n                PlayerEvent::Command(PlayerCommand::Seek { position: duration })\n            }\n            _ => {\n                return;\n            }\n        };\n        sender.send(cmd).unwrap();\n    }\n\n    fn update_media_control_playback(&mut self, playback: &Playback) {\n        if let Some(media_controls) = self.media_controls.as_mut() {\n            let progress = playback\n                .now_playing\n                .as_ref()\n                .map(|now_playing| MediaPosition(now_playing.progress));\n            media_controls\n                .set_playback(match playback.state {\n                    PlaybackState::Loading | PlaybackState::Stopped => MediaPlayback::Stopped,\n                    PlaybackState::Playing => MediaPlayback::Playing { progress },\n                    PlaybackState::Paused => MediaPlayback::Paused { progress },\n                })\n                .unwrap_or_default();\n        }\n    }\n\n    fn update_media_control_metadata(&mut self, playback: &Playback) {\n        if let Some(media_controls) = self.media_controls.as_mut() {\n            let title = playback.now_playing.as_ref().map(|p| p.item.name().clone());\n            let album = playback\n                .now_playing\n                .as_ref()\n                .and_then(|p| p.item.track())\n                .map(|t| t.album_name());\n            let artist = playback\n                .now_playing\n                .as_ref()\n                .and_then(|p| p.item.track())\n                .map(|t| t.artist_name());\n            let duration = playback.now_playing.as_ref().map(|p| p.item.duration());\n            let cover_url = playback\n                .now_playing\n                .as_ref()\n                .and_then(|p| p.cover_image_url(512.0, 512.0));\n            media_controls\n                .set_metadata(MediaMetadata {\n                    title: title.as_deref(),\n                    album: album.as_deref(),\n                    artist: artist.as_deref(),\n                    duration,\n                    cover_url,\n                })\n                .unwrap();\n        }\n    }\n\n    fn send(&mut self, event: PlayerEvent) {\n        if let Some(s) = &self.sender {\n            s.send(event)\n                .map_err(|e| log::error!(\"error sending message: {e:?}\"))\n                .ok();\n        }\n    }\n\n    fn report_now_playing(&mut self, playback: &Playback) {\n        if let Some(now_playing) = playback.now_playing.as_ref() {\n            if let Playable::Track(track) = &now_playing.item {\n                if let Some(scrobbler) = &self.scrobbler {\n                    let artist = track.artist_name();\n                    let title = track.name.clone();\n                    let album = track.album.clone();\n\n                    if let Err(e) = LastFmClient::now_playing_song(\n                        scrobbler,\n                        artist.as_ref(),\n                        title.as_ref(),\n                        album.as_ref().map(|a| a.name.as_ref()),\n                    ) {\n                        log::warn!(\"failed to report 'Now Playing' to Last.fm: {e}\");\n                    } else {\n                        log::info!(\"reported 'Now Playing' to Last.fm: {artist} - {title}\");\n                    }\n                } else {\n                    log::debug!(\"Last.fm not configured, skipping now_playing report.\");\n                }\n            }\n        }\n    }\n\n    fn report_scrobble(&mut self, playback: &Playback) {\n        if let Some(now_playing) = playback.now_playing.as_ref() {\n            if let Playable::Track(track) = &now_playing.item {\n                if now_playing.progress >= track.duration / 2 && !self.has_scrobbled {\n                    if let Some(scrobbler) = &self.scrobbler {\n                        let artist = track.artist_name();\n                        let title = track.name.clone();\n                        let album = track.album.clone();\n\n                        if let Err(e) = LastFmClient::scrobble_song(\n                            scrobbler,\n                            artist.as_ref(),\n                            title.as_ref(),\n                            album.as_ref().map(|a| a.name.as_ref()),\n                        ) {\n                            log::warn!(\"failed to scrobble track to Last.fm: {e}\");\n                        } else {\n                            log::info!(\"scrobbled track to Last.fm: {artist} - {title}\");\n                            self.has_scrobbled = true;\n                        }\n                    } else {\n                        log::debug!(\"Last.fm not configured, skipping scrobble.\");\n                    }\n                }\n            }\n        }\n    }\n\n    fn play(&mut self, items: &Vector<QueueEntry>, position: usize) {\n        let playback_items = items.iter().map(|queued| PlaybackItem {\n            item_id: queued.item.id(),\n            norm_level: match queued.origin {\n                PlaybackOrigin::Album(_) => NormalizationLevel::Album,\n                _ => NormalizationLevel::Track,\n            },\n        });\n        let playback_items_vec: Vec<PlaybackItem> = playback_items.collect();\n\n        // Make sure position is within bounds\n        let position = if position >= playback_items_vec.len() {\n            0\n        } else {\n            position\n        };\n\n        self.send(PlayerEvent::Command(PlayerCommand::LoadQueue {\n            items: playback_items_vec,\n            position,\n        }));\n    }\n\n    fn pause(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::Pause));\n    }\n\n    fn resume(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::Resume));\n    }\n\n    fn pause_or_resume(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::PauseOrResume));\n    }\n\n    fn previous(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::Previous));\n    }\n\n    fn next(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::Next));\n    }\n\n    fn stop(&mut self) {\n        self.send(PlayerEvent::Command(PlayerCommand::Stop));\n    }\n\n    fn seek(&mut self, position: Duration) {\n        self.send(PlayerEvent::Command(PlayerCommand::Seek { position }));\n    }\n\n    fn seek_relative(&mut self, data: &AppState, forward: bool) {\n        if let Some(now_playing) = &data.playback.now_playing {\n            let seek_duration = Duration::from_secs(data.config.seek_duration as u64);\n\n            // Calculate new position, ensuring it does not exceed duration for forward seeks.\n            let seek_position = if forward {\n                now_playing.progress + seek_duration\n            } else {\n                now_playing.progress.saturating_sub(seek_duration)\n            }\n            .min(now_playing.item.duration());\n\n            self.seek(seek_position);\n        }\n    }\n\n    fn set_volume(&mut self, volume: f64) {\n        self.send(PlayerEvent::Command(PlayerCommand::SetVolume { volume }));\n    }\n\n    fn add_to_queue(&mut self, item: &PlaybackItem) {\n        self.send(PlayerEvent::Command(PlayerCommand::AddToQueue {\n            item: *item,\n        }));\n    }\n\n    fn set_queue_behavior(&mut self, behavior: QueueBehavior) {\n        self.send(PlayerEvent::Command(PlayerCommand::SetQueueBehavior {\n            behavior: match behavior {\n                QueueBehavior::Sequential => psst_core::player::queue::QueueBehavior::Sequential,\n                QueueBehavior::Random => psst_core::player::queue::QueueBehavior::Random,\n                QueueBehavior::LoopTrack => psst_core::player::queue::QueueBehavior::LoopTrack,\n                QueueBehavior::LoopAll => psst_core::player::queue::QueueBehavior::LoopAll,\n            },\n        }));\n    }\n\n    fn update_lyrics(&mut self, ctx: &mut EventCtx, data: &AppState, now_playing: &NowPlaying) {\n        if matches!(data.nav, Nav::Lyrics) {\n            ctx.submit_command(lyrics::SHOW_LYRICS.with(now_playing.clone()));\n        }\n    }\n}\n\nimpl<W> Controller<AppState, W> for PlaybackController\nwhere\n    W: Widget<AppState>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(cmd::SET_FOCUS) => {\n                ctx.request_focus();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_LOADING) => {\n                let item = cmd.get_unchecked(cmd::PLAYBACK_LOADING);\n\n                if let Some(queued) = data.queued_entry(*item) {\n                    data.loading_playback(queued.item, queued.origin);\n                    self.update_media_control_playback(&data.playback);\n                    self.update_media_control_metadata(&data.playback);\n                } else {\n                    log::warn!(\"loaded item not found in playback queue\");\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PLAYING) => {\n                let (item, progress) = cmd.get_unchecked(cmd::PLAYBACK_PLAYING);\n\n                // Song has changed, so we reset the has_scrobbled value\n                self.has_scrobbled = false;\n                self.report_now_playing(&data.playback);\n\n                if let Some(queued) = data.queued_entry(*item) {\n                    data.start_playback(queued.item, queued.origin, progress.to_owned());\n                    self.update_media_control_playback(&data.playback);\n                    self.update_media_control_metadata(&data.playback);\n                    if let Some(now_playing) = &data.playback.now_playing {\n                        self.update_lyrics(ctx, data, now_playing);\n                    }\n                } else {\n                    log::warn!(\"played item not found in playback queue\");\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PROGRESS) => {\n                let progress = cmd.get_unchecked(cmd::PLAYBACK_PROGRESS);\n                data.progress_playback(progress.to_owned());\n\n                self.report_scrobble(&data.playback);\n                self.update_media_control_playback(&data.playback);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PAUSING) => {\n                data.pause_playback();\n                self.update_media_control_playback(&data.playback);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_RESUMING) => {\n                data.resume_playback();\n                self.update_media_control_playback(&data.playback);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_BLOCKED) => {\n                data.block_playback();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAYBACK_STOPPED) => {\n                data.stop_playback();\n                self.update_media_control_playback(&data.playback);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_TRACKS) => {\n                let payload = cmd.get_unchecked(cmd::PLAY_TRACKS);\n                data.playback.queue = payload\n                    .items\n                    .iter()\n                    .map(|item| QueueEntry {\n                        origin: payload.origin.to_owned(),\n                        item: item.to_owned(),\n                    })\n                    .collect();\n\n                self.play(&data.playback.queue, payload.position);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_PAUSE) => {\n                self.pause();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_RESUME) => {\n                self.resume();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_PREVIOUS) => {\n                self.previous();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_NEXT) => {\n                self.next();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_STOP) => {\n                self.stop();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::ADD_TO_QUEUE) => {\n                log::info!(\"adding to queue\");\n                let (entry, item) = cmd.get_unchecked(cmd::ADD_TO_QUEUE);\n\n                self.add_to_queue(item);\n                data.add_queued_entry(entry.clone());\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_QUEUE_BEHAVIOR) => {\n                let behavior = cmd.get_unchecked(cmd::PLAY_QUEUE_BEHAVIOR);\n                data.set_queue_behavior(behavior.to_owned());\n                self.set_queue_behavior(behavior.to_owned());\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::PLAY_SEEK) => {\n                if let Some(now_playing) = &data.playback.now_playing {\n                    let fraction = cmd.get_unchecked(cmd::PLAY_SEEK);\n                    let position = Duration::from_secs_f64(\n                        now_playing.item.duration().as_secs_f64() * fraction,\n                    );\n                    self.seek(position);\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::SKIP_TO_POSITION) => {\n                let location = cmd.get_unchecked(cmd::SKIP_TO_POSITION);\n                self.seek(Duration::from_millis(*location));\n                ctx.set_handled();\n            }\n            // Keyboard shortcuts.\n            Event::KeyDown(key) if key.code == Code::Space => {\n                self.pause_or_resume();\n                ctx.set_handled();\n            }\n            Event::KeyDown(key) if key.code == Code::ArrowRight => {\n                if key.mods.shift() {\n                    self.next();\n                } else {\n                    self.seek_relative(data, true);\n                }\n                ctx.set_handled();\n            }\n            Event::KeyDown(key) if key.code == Code::ArrowLeft => {\n                if key.mods.shift() {\n                    self.previous();\n                } else {\n                    self.seek_relative(data, false);\n                }\n                ctx.set_handled();\n            }\n            Event::KeyDown(key) if key.key == KbKey::Character(\"+\".to_string()) => {\n                data.playback.volume = (data.playback.volume + 0.1).min(1.0);\n                ctx.set_handled();\n            }\n            Event::KeyDown(key) if key.key == KbKey::Character(\"-\".to_string()) => {\n                data.playback.volume = (data.playback.volume - 0.1).max(0.0);\n                ctx.set_handled();\n            }\n            _ => child.event(ctx, event, data, env),\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &AppState,\n        env: &Env,\n    ) {\n        match event {\n            LifeCycle::WidgetAdded => {\n                self.open_audio_output_and_start_threads(\n                    data.session.clone(),\n                    data.config.playback(),\n                    ctx.get_external_handle(),\n                    ctx.widget_id(),\n                    ctx.window(),\n                );\n\n                // Initialize values loaded from the config.\n                self.set_volume(data.playback.volume);\n                self.set_queue_behavior(data.playback.queue_behavior);\n\n                // Request focus so we can receive keyboard events.\n                ctx.submit_command(cmd::SET_FOCUS.to(ctx.widget_id()));\n            }\n            LifeCycle::Internal(InternalLifeCycle::RouteFocusChanged { new: None, .. }) => {\n                // Druid doesn't have any \"ambient focus\" concept, so we catch the situation\n                // when the focus is being lost and sign up to get focused ourselves.\n                ctx.submit_command(cmd::SET_FOCUS.to(ctx.widget_id()));\n            }\n            _ => {}\n        }\n        if self.startup {\n            self.startup = false;\n            self.scrobbler = init_scrobbler_instance(data);\n        }\n        child.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(\n        &mut self,\n        child: &mut W,\n        ctx: &mut UpdateCtx,\n        old_data: &AppState,\n        data: &AppState,\n        env: &Env,\n    ) {\n        if !old_data.playback.volume.same(&data.playback.volume) {\n            self.set_volume(data.playback.volume);\n        }\n\n        let lastfm_changed = old_data.config.lastfm_api_key != data.config.lastfm_api_key\n            || old_data.config.lastfm_api_secret != data.config.lastfm_api_secret\n            || old_data.config.lastfm_session_key != data.config.lastfm_session_key\n            || old_data.config.lastfm_enable != data.config.lastfm_enable;\n\n        if lastfm_changed {\n            self.scrobbler = init_scrobbler_instance(data);\n        }\n\n        child.update(ctx, old_data, data, env);\n    }\n}\n\n// This uses the current system time to generate a random lowercase string of a given length.\nfn random_lowercase_string(len: usize) -> String {\n    let now = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .unwrap()\n        .as_secs();\n\n    let mut n = now;\n    let mut chars = Vec::new();\n    while n > 0 && chars.len() < len {\n        let c = ((n % 26) as u8 + b'a') as char;\n        chars.push(c);\n        n /= 26;\n    }\n    while chars.len() < len {\n        chars.push('a');\n    }\n    chars.into_iter().rev().collect()\n}\n"
  },
  {
    "path": "psst-gui/src/controller/session.rs",
    "content": "use druid::widget::{prelude::*, Controller};\n\nuse crate::{\n    cmd,\n    data::AppState,\n    ui::{home, playlist, user},\n};\n\npub struct SessionController;\n\nimpl SessionController {\n    fn connect(&self, ctx: &mut EventCtx, data: &mut AppState) {\n        // Update the session configuration, any active session will get shut down.\n        data.session.update_config(data.config.session());\n\n        // Reload the global, usually visible data.\n        ctx.submit_command(playlist::LOAD_LIST);\n        ctx.submit_command(home::LOAD_MADE_FOR_YOU);\n        ctx.submit_command(user::LOAD_PROFILE);\n    }\n}\n\nimpl<W> Controller<AppState, W> for SessionController\nwhere\n    W: Widget<AppState>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(cmd::SESSION_CONNECT) => {\n                if data.config.has_credentials() {\n                    self.connect(ctx, data);\n                }\n                ctx.set_handled();\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &AppState,\n        env: &Env,\n    ) {\n        if let LifeCycle::WidgetAdded = event {\n            ctx.submit_command(cmd::SESSION_CONNECT);\n        }\n        child.lifecycle(ctx, event, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/controller/sort.rs",
    "content": "use druid::widget::{prelude::*, Controller};\nuse druid::{Event, EventCtx, Widget};\n\nuse crate::cmd;\nuse crate::data::config::SortCriteria;\nuse crate::data::{config::SortOrder, AppState};\n\npub struct SortController;\n\nimpl<W> Controller<AppState, W> for SortController\nwhere\n    W: Widget<AppState>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(cmd::TOGGLE_SORT_ORDER) => {\n                if data.config.sort_order == SortOrder::Ascending {\n                    data.config.sort_order = SortOrder::Descending;\n                } else {\n                    data.config.sort_order = SortOrder::Ascending;\n                }\n\n                ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::SORT_BY_TITLE) => {\n                if data.config.sort_criteria != SortCriteria::Title {\n                    data.config.sort_criteria = SortCriteria::Title;\n                    ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                    ctx.set_handled();\n                }\n            }\n            Event::Command(cmd) if cmd.is(cmd::SORT_BY_ALBUM) => {\n                if data.config.sort_criteria != SortCriteria::Album {\n                    data.config.sort_criteria = SortCriteria::Album;\n                    ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                    ctx.set_handled();\n                }\n            }\n            Event::Command(cmd) if cmd.is(cmd::SORT_BY_DATE_ADDED) => {\n                if data.config.sort_criteria != SortCriteria::DateAdded {\n                    data.config.sort_criteria = SortCriteria::DateAdded;\n                    ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                    ctx.set_handled();\n                }\n            }\n            Event::Command(cmd) if cmd.is(cmd::SORT_BY_ARTIST) => {\n                if data.config.sort_criteria != SortCriteria::Artist {\n                    data.config.sort_criteria = SortCriteria::Artist;\n                    ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                    ctx.set_handled();\n                }\n            }\n            Event::Command(cmd) if cmd.is(cmd::SORT_BY_DURATION) => {\n                if data.config.sort_criteria != SortCriteria::Duration {\n                    data.config.sort_criteria = SortCriteria::Duration;\n                    ctx.submit_command(cmd::NAVIGATE_REFRESH);\n                    ctx.set_handled();\n                }\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/album.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{im::Vector, Data, Lens};\nuse serde::{Deserialize, Serialize};\nuse time::{formatting::Formattable, macros::format_description, Date};\n\nuse crate::data::{ArtistLink, Cached, Image, Promise, Track};\n\n#[derive(Clone, Data, Lens)]\npub struct AlbumDetail {\n    pub album: Promise<Cached<Arc<Album>>, AlbumLink>,\n}\n\n#[derive(Clone, Data, Lens, Deserialize)]\npub struct Album {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    pub album_type: AlbumType,\n    #[serde(default)]\n    pub images: Vector<Image>,\n    #[serde(default)]\n    pub artists: Vector<ArtistLink>,\n    #[serde(default)]\n    pub copyrights: Vector<Copyright>,\n    #[serde(default = \"super::utils::default_str\")]\n    #[serde(deserialize_with = \"super::utils::deserialize_null_arc_str\")]\n    pub label: Arc<str>,\n    #[serde(default)]\n    #[serde(deserialize_with = \"super::utils::deserialize_first_page\")]\n    pub tracks: Vector<Arc<Track>>,\n    #[serde(deserialize_with = \"super::utils::deserialize_date_option\")]\n    #[data(same_fn = \"PartialEq::eq\")]\n    pub release_date: Option<Date>,\n    #[data(same_fn = \"PartialEq::eq\")]\n    pub release_date_precision: Option<DatePrecision>,\n}\n\nimpl Album {\n    pub fn release(&self) -> String {\n        self.release_with_format(match self.release_date_precision {\n            Some(DatePrecision::Year) | None => format_description!(\"[year]\"),\n            Some(DatePrecision::Month) => format_description!(\"[month repr:long] [year]\"),\n            Some(DatePrecision::Day) => format_description!(\"[month repr:long] [day], [year]\"),\n        })\n    }\n\n    pub fn release_year(&self) -> String {\n        self.release_with_format(format_description!(\"[year]\"))\n    }\n\n    pub fn release_year_int(&self) -> usize {\n        self.release_year().parse::<usize>().unwrap_or_else(|err| {\n            log::error!(\"error parsing release year for {}: {}\", self.name, err);\n            usize::MAX\n        })\n    }\n\n    fn release_with_format(&self, format: &(impl Formattable + ?Sized)) -> String {\n        self.release_date\n            .as_ref()\n            .map(|date| date.format(format).expect(\"invalid format\"))\n            .unwrap_or_default()\n    }\n\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\n        Image::at_least_of_size(&self.images, width, height)\n    }\n\n    pub fn url(&self) -> String {\n        format!(\"https://open.spotify.com/album/{id}\", id = self.id)\n    }\n\n    pub fn link(&self) -> AlbumLink {\n        AlbumLink {\n            id: self.id.clone(),\n            name: self.name.clone(),\n            images: self.images.clone(),\n        }\n    }\n\n    pub fn has_explicit(&self) -> bool {\n        self.tracks.iter().any(|t| t.explicit)\n    }\n\n    pub fn into_tracks_with_context(self: Arc<Self>) -> Vector<Arc<Track>> {\n        let album_link = self.link();\n        self.tracks\n            .iter()\n            .map(|track| {\n                let mut track = track.as_ref().clone();\n                track.album = Some(album_link.clone());\n                Arc::new(track)\n            })\n            .collect()\n    }\n}\n\n#[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)]\npub struct AlbumLink {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    #[serde(default)]\n    pub images: Vector<Image>,\n}\n\nimpl AlbumLink {\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\n        Image::at_least_of_size(&self.images, width, height)\n    }\n}\n\n#[derive(Clone, Debug, Data, Eq, PartialEq, Hash, Deserialize, Serialize, Default)]\n#[serde(rename_all = \"snake_case\")]\npub enum AlbumType {\n    #[default]\n    Album,\n    Single,\n    Compilation,\n    AppearsOn,\n}\n\n#[derive(Clone, Debug, Eq, PartialEq, Data, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum DatePrecision {\n    Year,\n    Month,\n    Day,\n}\n\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\npub struct Copyright {\n    pub text: Arc<str>,\n    #[serde(rename = \"type\")]\n    pub kind: CopyrightType,\n}\n\n#[derive(Copy, Clone, Debug, Eq, PartialEq, Data, Deserialize)]\npub enum CopyrightType {\n    #[serde(rename = \"C\")]\n    Copyright,\n    #[serde(rename = \"P\")]\n    Performance,\n}\n"
  },
  {
    "path": "psst-gui/src/data/artist.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{im::Vector, Data, Lens};\nuse serde::{Deserialize, Serialize};\n\nuse crate::data::{Album, Cached, Image, Promise, Track};\n\n#[derive(Clone, Data, Lens)]\npub struct ArtistDetail {\n    pub artist: Promise<Artist, ArtistLink>,\n    pub albums: Promise<ArtistAlbums, ArtistLink>,\n    pub top_tracks: Promise<ArtistTracks, ArtistLink>,\n    pub related_artists: Promise<Cached<Vector<Artist>>, ArtistLink>,\n    pub artist_info: Promise<ArtistInfo, ArtistLink>,\n}\n\n#[derive(Clone, Data, Lens, Deserialize)]\npub struct Artist {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    pub images: Vector<Image>,\n}\n\nimpl Artist {\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\n        Image::at_least_of_size(&self.images, width, height)\n    }\n\n    pub fn link(&self) -> ArtistLink {\n        ArtistLink {\n            id: self.id.clone(),\n            name: self.name.clone(),\n        }\n    }\n}\n\n#[derive(Clone, Data, Lens)]\npub struct ArtistAlbums {\n    pub albums: Vector<Arc<Album>>,\n    pub singles: Vector<Arc<Album>>,\n    pub compilations: Vector<Arc<Album>>,\n    pub appears_on: Vector<Arc<Album>>,\n}\n#[derive(Clone, Data, Lens)]\npub struct ArtistInfo {\n    pub main_image: Arc<str>,\n    pub stats: ArtistStats,\n    pub bio: String,\n    pub artist_links: Vector<String>,\n}\n\n#[derive(Clone, Data, Lens)]\npub struct ArtistStats {\n    pub followers: i64,\n    pub monthly_listeners: i64,\n    pub world_rank: i64,\n}\n\n#[derive(Clone, Data, Lens)]\npub struct ArtistTracks {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    pub tracks: Vector<Arc<Track>>,\n}\n\nimpl ArtistTracks {\n    pub fn link(&self) -> ArtistLink {\n        ArtistLink {\n            id: self.id.clone(),\n            name: self.name.clone(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)]\npub struct ArtistLink {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n}\n\nimpl ArtistLink {\n    pub fn url(&self) -> String {\n        format!(\"https://open.spotify.com/artist/{id}\", id = self.id)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/config.rs",
    "content": "use std::{\n    env::{self, VarError},\n    fs::{self, File, OpenOptions},\n    io::{BufReader, BufWriter},\n    path::{Path, PathBuf},\n};\n\n#[cfg(target_family = \"unix\")]\nuse std::os::unix::fs::OpenOptionsExt;\n\nuse druid::{Data, Lens, Size};\nuse platform_dirs::AppDirs;\nuse psst_core::{\n    cache::{mkdir_if_not_exists, CacheHandle},\n    connection::Credentials,\n    player::PlaybackConfig,\n    session::{SessionConfig, SessionConnection},\n};\nuse serde::{Deserialize, Serialize};\n\nuse super::{Nav, Promise, QueueBehavior, SliderScrollScale};\nuse crate::ui::theme;\n\n#[derive(Clone, Debug, Data, Lens)]\npub struct Preferences {\n    pub active: PreferencesTab,\n    #[data(ignore)]\n    pub cache: Option<CacheHandle>,\n    pub cache_size: Promise<u64, (), ()>,\n    pub auth: Authentication,\n    pub lastfm_auth_result: Option<String>,\n}\n\nimpl Preferences {\n    pub fn reset(&mut self) {\n        self.cache_size.clear();\n        self.auth.result.clear();\n        self.auth.lastfm_api_key_input.clear();\n        self.auth.lastfm_api_secret_input.clear();\n    }\n\n    pub fn measure_cache_usage() -> Option<u64> {\n        Config::cache_dir().and_then(|path| get_dir_size(&path))\n    }\n}\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq, Data)]\npub enum PreferencesTab {\n    General,\n    Account,\n    Cache,\n    About,\n}\n\n#[derive(Clone, Debug, Data, Lens)]\npub struct Authentication {\n    pub username: String,\n    pub password: String,\n    pub access_token: String,\n    pub result: Promise<(), (), String>,\n    #[data(ignore)]\n    pub lastfm_api_key_input: String,\n    #[data(ignore)]\n    pub lastfm_api_secret_input: String,\n}\n\nimpl Authentication {\n    pub fn new() -> Self {\n        Self {\n            username: String::new(),\n            password: String::new(),\n            access_token: String::new(),\n            result: Promise::Empty,\n            lastfm_api_key_input: String::new(),\n            lastfm_api_secret_input: String::new(),\n        }\n    }\n\n    pub fn session_config(&self) -> SessionConfig {\n        SessionConfig {\n            login_creds: if !self.access_token.is_empty() {\n                Credentials::from_access_token(self.access_token.clone())\n            } else {\n                Credentials::from_username_and_password(\n                    self.username.clone(),\n                    self.password.clone(),\n                )\n            },\n            proxy_url: Config::proxy(),\n        }\n    }\n\n    pub fn authenticate_and_get_credentials(config: SessionConfig) -> Result<Credentials, String> {\n        let connection = SessionConnection::open(config).map_err(|err| err.to_string())?;\n        Ok(connection.credentials)\n    }\n\n    pub fn clear(&mut self) {\n        self.username.clear();\n        self.password.clear();\n    }\n}\n\nconst APP_NAME: &str = \"Psst\";\nconst CONFIG_FILENAME: &str = \"config.json\";\nconst PROXY_ENV_VAR: &str = \"SOCKS_PROXY\";\n\n#[derive(Clone, Debug, Data, Lens, Serialize, Deserialize)]\n#[serde(default)]\npub struct Config {\n    #[data(ignore)]\n    credentials: Option<Credentials>,\n    pub audio_quality: AudioQuality,\n    pub theme: Theme,\n    pub volume: f64,\n    pub last_route: Option<Nav>,\n    pub queue_behavior: QueueBehavior,\n    pub show_track_cover: bool,\n    pub window_size: Size,\n    pub slider_scroll_scale: SliderScrollScale,\n    pub sort_order: SortOrder,\n    pub sort_criteria: SortCriteria,\n    pub paginated_limit: usize,\n    pub seek_duration: usize,\n    pub lastfm_session_key: Option<String>,\n    pub lastfm_api_key: Option<String>,\n    pub lastfm_api_secret: Option<String>,\n    pub lastfm_enable: bool,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            credentials: Default::default(),\n            audio_quality: Default::default(),\n            theme: Default::default(),\n            volume: 1.0,\n            last_route: Default::default(),\n            queue_behavior: Default::default(),\n            show_track_cover: Default::default(),\n            window_size: Size::new(theme::grid(80.0), theme::grid(100.0)),\n            slider_scroll_scale: Default::default(),\n            sort_order: Default::default(),\n            sort_criteria: Default::default(),\n            paginated_limit: 500,\n            seek_duration: 10,\n            lastfm_session_key: None,\n            lastfm_api_key: None,\n            lastfm_api_secret: None,\n            lastfm_enable: false,\n        }\n    }\n}\n\nimpl Config {\n    fn app_dirs() -> Option<AppDirs> {\n        const USE_XDG_ON_MACOS: bool = false;\n\n        AppDirs::new(Some(APP_NAME), USE_XDG_ON_MACOS)\n    }\n\n    pub fn spotify_local_files_file(username: &str) -> Option<PathBuf> {\n        AppDirs::new(Some(\"spotify\"), false).map(|dir| {\n            let path = format!(\"Users/{username}-user/local-files.bnk\");\n            dir.config_dir.join(path)\n        })\n    }\n\n    pub fn cache_dir() -> Option<PathBuf> {\n        Self::app_dirs().map(|dirs| dirs.cache_dir)\n    }\n\n    pub fn config_dir() -> Option<PathBuf> {\n        Self::app_dirs().map(|dirs| dirs.config_dir)\n    }\n\n    fn config_path() -> Option<PathBuf> {\n        Self::config_dir().map(|dir| dir.join(CONFIG_FILENAME))\n    }\n\n    pub fn load() -> Option<Config> {\n        let path = Self::config_path().expect(\"Failed to get config path\");\n        if let Ok(file) = File::open(&path) {\n            log::info!(\"loading config: {:?}\", &path);\n            let reader = BufReader::new(file);\n            Some(serde_json::from_reader(reader).expect(\"Failed to read config\"))\n        } else {\n            None\n        }\n    }\n\n    pub fn save(&self) {\n        let dir = Self::config_dir().expect(\"Failed to get config dir\");\n        let path = Self::config_path().expect(\"Failed to get config path\");\n        mkdir_if_not_exists(&dir).expect(\"Failed to create config dir\");\n\n        let mut options = OpenOptions::new();\n        options.write(true).create(true).truncate(true);\n        #[cfg(target_family = \"unix\")]\n        options.mode(0o600);\n\n        let file = options.open(&path).expect(\"Failed to create config\");\n        let writer = BufWriter::new(file);\n\n        serde_json::to_writer_pretty(writer, self).expect(\"Failed to write config\");\n        log::info!(\"saved config: {:?}\", &path);\n    }\n\n    pub fn has_credentials(&self) -> bool {\n        self.credentials.is_some()\n    }\n\n    pub fn store_credentials(&mut self, credentials: Credentials) {\n        self.credentials = Some(credentials);\n    }\n\n    pub fn clear_credentials(&mut self) {\n        self.credentials = Default::default();\n    }\n\n    pub fn username(&self) -> Option<&str> {\n        self.credentials\n            .as_ref()\n            .and_then(|c| c.username.as_deref())\n    }\n\n    pub fn session(&self) -> SessionConfig {\n        SessionConfig {\n            login_creds: self.credentials.clone().expect(\"Missing credentials\"),\n            proxy_url: Config::proxy(),\n        }\n    }\n\n    pub fn playback(&self) -> PlaybackConfig {\n        PlaybackConfig {\n            bitrate: self.audio_quality.as_bitrate(),\n            ..PlaybackConfig::default()\n        }\n    }\n\n    pub fn proxy() -> Option<String> {\n        env::var(PROXY_ENV_VAR).map_or_else(\n            |err| match err {\n                VarError::NotPresent => None,\n                VarError::NotUnicode(_) => {\n                    log::error!(\"proxy URL is not a valid unicode\");\n                    None\n                }\n            },\n            Some,\n        )\n    }\n}\n\n#[derive(Copy, Clone, Debug, Eq, PartialEq, Data, Serialize, Deserialize, Default)]\npub enum AudioQuality {\n    Low,\n    Normal,\n    #[default]\n    High,\n}\n\nimpl AudioQuality {\n    fn as_bitrate(self) -> usize {\n        match self {\n            AudioQuality::Low => 96,\n            AudioQuality::Normal => 160,\n            AudioQuality::High => 320,\n        }\n    }\n}\n\n#[derive(Copy, Clone, Debug, Eq, PartialEq, Data, Serialize, Deserialize, Default)]\npub enum Theme {\n    #[default]\n    Light,\n    Dark,\n}\n\n#[derive(Copy, Clone, Debug, Eq, PartialEq, Data, Serialize, Deserialize, Default)]\npub enum SortOrder {\n    #[default]\n    Ascending,\n    Descending,\n}\n\n#[derive(Copy, Clone, Debug, Eq, PartialEq, Data, Serialize, Deserialize, Default)]\npub enum SortCriteria {\n    Title,\n    Artist,\n    Album,\n    Duration,\n    #[default]\n    DateAdded,\n}\n\nfn get_dir_size(path: &Path) -> Option<u64> {\n    fs::read_dir(path).ok()?.try_fold(0, |acc, entry| {\n        let entry = entry.ok()?;\n        let size = if entry.file_type().ok()?.is_dir() {\n            get_dir_size(&entry.path())?\n        } else {\n            entry.metadata().ok()?.len()\n        };\n        Some(acc + size)\n    })\n}\n"
  },
  {
    "path": "psst-gui/src/data/ctx.rs",
    "content": "use std::fmt;\n\nuse druid::{\n    lens::{Field, Map},\n    widget::ListIter,\n    Data, Lens, LensExt,\n};\n\nuse crate::data::Promise;\n\n#[derive(Clone, Data)]\npub struct Ctx<C, T> {\n    pub ctx: C,\n    pub data: T,\n}\n\nimpl<C, T> Ctx<C, T>\nwhere\n    C: Data,\n    T: Data,\n{\n    pub fn new(c: C, t: T) -> Self {\n        Self { ctx: c, data: t }\n    }\n\n    pub fn make<S: Data>(cl: impl Lens<S, C>, tl: impl Lens<S, T>) -> impl Lens<S, Self> {\n        CtxMake { cl, tl }\n    }\n\n    pub fn data() -> impl Lens<Self, T> {\n        Field::new(|c: &Self| &c.data, |c: &mut Self| &mut c.data)\n    }\n\n    pub fn map<U>(map: impl Lens<T, U>) -> impl Lens<Self, Ctx<C, U>>\n    where\n        U: Data,\n    {\n        CtxMap { map }\n    }\n}\n\nstruct CtxMake<CL, TL> {\n    cl: CL,\n    tl: TL,\n}\n\nimpl<C, T, S, CL, TL> Lens<S, Ctx<C, T>> for CtxMake<CL, TL>\nwhere\n    C: Data,\n    T: Data,\n    S: Data,\n    CL: Lens<S, C>,\n    TL: Lens<S, T>,\n{\n    fn with<V, F>(&self, data: &S, f: F) -> V\n    where\n        F: FnOnce(&Ctx<C, T>) -> V,\n    {\n        let c = self.cl.get(data);\n        let t = self.tl.get(data);\n        let ct = Ctx::new(c, t);\n        f(&ct)\n    }\n\n    fn with_mut<V, F>(&self, data: &mut S, f: F) -> V\n    where\n        F: FnOnce(&mut Ctx<C, T>) -> V,\n    {\n        let c = self.cl.get(data);\n        let t = self.tl.get(data);\n        let mut ct = Ctx::new(c, t);\n        let v = f(&mut ct);\n        self.cl.put(data, ct.ctx);\n        self.tl.put(data, ct.data);\n        v\n    }\n}\n\nstruct CtxMap<Map> {\n    map: Map,\n}\n\nimpl<C, T, U, Map> Lens<Ctx<C, T>, Ctx<C, U>> for CtxMap<Map>\nwhere\n    C: Data,\n    T: Data,\n    U: Data,\n    Map: Lens<T, U>,\n{\n    fn with<V, F>(&self, c: &Ctx<C, T>, f: F) -> V\n    where\n        F: FnOnce(&Ctx<C, U>) -> V,\n    {\n        self.map.with(&c.data, |u| {\n            let cu = Ctx::new(c.ctx.to_owned(), u.to_owned());\n            f(&cu)\n        })\n    }\n\n    fn with_mut<V, F>(&self, c: &mut Ctx<C, T>, f: F) -> V\n    where\n        F: FnOnce(&mut Ctx<C, U>) -> V,\n    {\n        let t = &mut c.data;\n        let c = &mut c.ctx;\n        self.map.with_mut(t, |u| {\n            let mut cu = Ctx::new(c.to_owned(), u.to_owned());\n            let v = f(&mut cu);\n            *c = cu.ctx;\n            *u = cu.data;\n            v\n        })\n    }\n}\n\nimpl<C, PT, PD, PE> Ctx<C, Promise<PT, PD, PE>>\nwhere\n    C: Data,\n    PT: Data,\n    PD: Data,\n    PE: Data,\n{\n    pub fn in_promise() -> impl Lens<Self, Promise<Ctx<C, PT>, PD, PE>> {\n        Map::new(\n            |c: &Self| match &c.data {\n                Promise::Empty => Promise::Empty,\n                Promise::Deferred { def } => Promise::Deferred {\n                    def: def.to_owned(),\n                },\n                Promise::Resolved { def, val } => Promise::Resolved {\n                    def: def.to_owned(),\n                    val: Ctx::new(c.ctx.to_owned(), val.to_owned()),\n                },\n                Promise::Rejected { def, err } => Promise::Rejected {\n                    def: def.to_owned(),\n                    err: err.to_owned(),\n                },\n            },\n            |c: &mut Self, p: Promise<Ctx<C, PT>, PD, PE>| match p {\n                Promise::Empty => {\n                    c.data = Promise::Empty;\n                }\n                Promise::Deferred { def } => {\n                    c.data = Promise::Deferred { def };\n                }\n                Promise::Resolved { def, val } => {\n                    c.data = Promise::Resolved { def, val: val.data };\n                    c.ctx = val.ctx;\n                }\n                Promise::Rejected { def, err } => {\n                    c.data = Promise::Rejected { def, err };\n                }\n            },\n        )\n    }\n}\n\nimpl<C, T, L> ListIter<Ctx<C, T>> for Ctx<C, L>\nwhere\n    C: Data,\n    T: Data,\n    L: ListIter<T>,\n{\n    fn for_each(&self, mut cb: impl FnMut(&Ctx<C, T>, usize)) {\n        self.data.for_each(|item, index| {\n            let d = Ctx::new(self.ctx.to_owned(), item.to_owned());\n            cb(&d, index);\n        });\n    }\n\n    fn for_each_mut(&mut self, mut cb: impl FnMut(&mut Ctx<C, T>, usize)) {\n        let ctx = &mut self.ctx;\n        let data = &mut self.data;\n        data.for_each_mut(|item, index| {\n            let mut d = Ctx::new(ctx.to_owned(), item.to_owned());\n            cb(&mut d, index);\n            if !ctx.same(&d.ctx) {\n                *ctx = d.ctx;\n            }\n            if !item.same(&d.data) {\n                *item = d.data;\n            }\n        });\n    }\n\n    fn data_len(&self) -> usize {\n        self.data.data_len()\n    }\n}\n\nimpl<C, L> fmt::Debug for Ctx<C, L>\nwhere\n    L: fmt::Debug,\n{\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        self.data.fmt(f)\n    }\n}\n\nimpl<C, L> PartialEq for Ctx<C, L>\nwhere\n    L: PartialEq,\n{\n    fn eq(&self, other: &Self) -> bool {\n        self.data.eq(&other.data)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/find.rs",
    "content": "use druid::{Data, Lens};\nuse regex::{Regex, RegexBuilder};\n\n#[derive(Clone, Default, Debug, Data, Lens)]\npub struct Finder {\n    pub focused_result: usize,\n    pub results: usize,\n    pub show: bool,\n    pub query: String,\n}\n\nimpl Finder {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn reset(&mut self) {\n        self.query = String::new();\n        self.results = 0;\n        self.focused_result = 0;\n    }\n\n    pub fn reset_matches(&mut self) {\n        self.results = 0;\n    }\n\n    pub fn report_match(&mut self) -> usize {\n        self.results += 1;\n        self.results\n    }\n\n    pub fn focus_previous(&mut self) {\n        self.focused_result = if self.focused_result > 0 {\n            self.focused_result - 1\n        } else {\n            self.results.saturating_sub(1)\n        };\n    }\n\n    pub fn focus_next(&mut self) {\n        self.focused_result = if self.focused_result < self.results - 1 {\n            self.focused_result + 1\n        } else {\n            0\n        }\n    }\n}\n\n#[derive(Clone)]\npub struct FindQuery {\n    regex: Regex,\n}\n\nimpl FindQuery {\n    pub fn new(query: &str) -> Self {\n        Self {\n            regex: Self::build_regex(query),\n        }\n    }\n\n    fn build_regex(query: &str) -> Regex {\n        RegexBuilder::new(&regex::escape(query))\n            .case_insensitive(true)\n            .build()\n            .unwrap()\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.regex.as_str().is_empty()\n    }\n\n    pub fn matches_str(&self, s: &str) -> bool {\n        self.regex.is_match(s)\n    }\n}\n\npub trait MatchFindQuery {\n    fn matches_query(&self, query: &FindQuery) -> bool;\n}\n"
  },
  {
    "path": "psst-gui/src/data/id.rs",
    "content": "#[allow(dead_code)]\npub trait Id {\n    type Id: PartialEq;\n\n    fn id(&self) -> Self::Id;\n\n    fn has_id(&self, id: &Self::Id) -> bool {\n        id == &self.id()\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/mod.rs",
    "content": "mod album;\nmod artist;\npub mod config;\nmod ctx;\nmod find;\nmod id;\nmod nav;\nmod playback;\nmod playlist;\nmod promise;\nmod recommend;\nmod search;\nmod show;\nmod slider_scroll_scale;\nmod track;\nmod user;\npub mod utils;\n\nuse std::{\n    fmt::Display,\n    mem,\n    sync::{\n        atomic::{AtomicUsize, Ordering},\n        Arc,\n    },\n    time::{Duration, Instant},\n};\n\nuse druid::{\n    im::{HashSet, Vector},\n    Data, Lens,\n};\nuse psst_core::{item_id::ItemId, session::SessionService};\n\npub use crate::data::{\n    album::{Album, AlbumDetail, AlbumLink, AlbumType},\n    artist::{\n        Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistStats, ArtistTracks,\n    },\n    config::{AudioQuality, Authentication, Config, Preferences, PreferencesTab, Theme},\n    ctx::Ctx,\n    find::{FindQuery, Finder, MatchFindQuery},\n    nav::{Nav, Route, SpotifyUrl},\n    playback::{\n        NowPlaying, Playable, PlayableMatcher, Playback, PlaybackOrigin, PlaybackPayload,\n        PlaybackState, QueueBehavior, QueueEntry,\n    },\n    playlist::{\n        Playlist, PlaylistAddTrack, PlaylistDetail, PlaylistLink, PlaylistRemoveTrack,\n        PlaylistTracks,\n    },\n    promise::{Promise, PromiseState},\n    recommend::{\n        Range, Recommend, Recommendations, RecommendationsKnobs, RecommendationsParams,\n        RecommendationsRequest, Toggled,\n    },\n    search::{Search, SearchResults, SearchTopic},\n    show::{Episode, EpisodeId, EpisodeLink, Show, ShowDetail, ShowEpisodes, ShowLink},\n    slider_scroll_scale::SliderScrollScale,\n    track::{AudioAnalysis, Track, TrackId, TrackLines},\n    user::{PublicUser, UserProfile},\n    utils::{Cached, Float64, Image, Page},\n};\nuse crate::ui::credits::TrackCredits;\n\npub const ALERT_DURATION: Duration = Duration::from_secs(5);\n\n#[derive(Clone, Data, Lens)]\npub struct AppState {\n    #[data(ignore)]\n    pub session: SessionService,\n    pub nav: Nav,\n    pub history: Vector<Nav>,\n    pub config: Config,\n    pub preferences: Preferences,\n    pub playback: Playback,\n    pub search: Search,\n    pub recommend: Recommend,\n    pub album_detail: AlbumDetail,\n    pub artist_detail: ArtistDetail,\n    pub playlist_detail: PlaylistDetail,\n    pub show_detail: ShowDetail,\n    pub library: Arc<Library>,\n    pub common_ctx: Arc<CommonCtx>,\n    pub home_detail: HomeDetail,\n    pub alerts: Vector<Alert>,\n    pub finder: Finder,\n    pub added_queue: Vector<QueueEntry>,\n    pub lyrics: Promise<Vector<TrackLines>>,\n    pub credits: Option<TrackCredits>,\n}\n\nimpl AppState {\n    pub fn default_with_config(config: Config) -> Self {\n        let library = Arc::new(Library {\n            user_profile: Promise::Empty,\n            saved_albums: Promise::Empty,\n            saved_tracks: Promise::Empty,\n            saved_shows: Promise::Empty,\n            playlists: Promise::Empty,\n        });\n        let common_ctx = Arc::new(CommonCtx {\n            now_playing: None,\n            library: Arc::clone(&library),\n            show_track_cover: config.show_track_cover,\n            nav: Nav::Home,\n        });\n        let playback = Playback {\n            state: PlaybackState::Stopped,\n            now_playing: None,\n            queue_behavior: config.queue_behavior,\n            queue: Vector::new(),\n            volume: config.volume,\n        };\n        Self {\n            session: SessionService::empty(),\n            nav: Nav::Home,\n            history: Vector::new(),\n            config,\n            preferences: Preferences {\n                active: PreferencesTab::General,\n                cache: None,\n                cache_size: Promise::Empty,\n                auth: Authentication::new(),\n                lastfm_auth_result: None,\n            },\n            playback,\n            added_queue: Vector::new(),\n            search: Search {\n                input: \"\".into(),\n                topic: None,\n                results: Promise::Empty,\n            },\n            recommend: Recommend {\n                knobs: Default::default(),\n                results: Promise::Empty,\n            },\n            home_detail: HomeDetail {\n                made_for_you: Promise::Empty,\n                user_top_mixes: Promise::Empty,\n                best_of_artists: Promise::Empty,\n                recommended_stations: Promise::Empty,\n                your_shows: Promise::Empty,\n                shows_that_you_might_like: Promise::Empty,\n                uniquely_yours: Promise::Empty,\n                jump_back_in: Promise::Empty,\n                user_top_tracks: Promise::Empty,\n                user_top_artists: Promise::Empty,\n            },\n            album_detail: AlbumDetail {\n                album: Promise::Empty,\n            },\n            artist_detail: ArtistDetail {\n                artist: Promise::Empty,\n                albums: Promise::Empty,\n                top_tracks: Promise::Empty,\n                related_artists: Promise::Empty,\n                artist_info: Promise::Empty,\n            },\n            playlist_detail: PlaylistDetail {\n                playlist: Promise::Empty,\n                tracks: Promise::Empty,\n            },\n            show_detail: ShowDetail {\n                show: Promise::Empty,\n                episodes: Promise::Empty,\n            },\n            library,\n            common_ctx,\n            alerts: Vector::new(),\n            finder: Finder::new(),\n            lyrics: Promise::Empty,\n            credits: None,\n        }\n    }\n}\n\nimpl AppState {\n    pub fn navigate(&mut self, nav: &Nav) {\n        if &self.nav != nav {\n            let previous = mem::replace(&mut self.nav, nav.to_owned());\n            self.history.push_back(previous);\n            self.config.last_route.replace(nav.to_owned());\n            Arc::make_mut(&mut self.common_ctx).nav = nav.to_owned();\n        }\n    }\n\n    pub fn navigate_back(&mut self) {\n        if let Some(mut nav) = self.history.pop_back() {\n            if let Nav::SearchResults(query) = &nav {\n                if SpotifyUrl::parse(query).is_some() {\n                    nav = self.history.pop_back().unwrap_or(Nav::Home);\n                }\n            }\n\n            if let Nav::AlbumDetail(album, _) = nav {\n                nav = Nav::AlbumDetail(album, None);\n            }\n\n            self.nav = nav;\n            self.config.last_route.replace(self.nav.to_owned());\n            Arc::make_mut(&mut self.common_ctx).nav = self.nav.clone();\n        }\n    }\n\n    pub fn refresh_all(&mut self) {\n        self.album_detail.album = Promise::Empty;\n        self.artist_detail.artist_info = Promise::Empty;\n        self.artist_detail.albums = Promise::Empty;\n        self.artist_detail.artist = Promise::Empty;\n        self.artist_detail.related_artists = Promise::Empty;\n        self.artist_detail.top_tracks = Promise::Empty;\n        self.playlist_detail.playlist = Promise::Empty;\n        self.playlist_detail.tracks = Promise::Empty;\n        self.show_detail.episodes = Promise::Empty;\n        self.show_detail.show = Promise::Empty;\n    }\n\n    pub fn refresh_playlist(&mut self) {\n        self.playlist_detail.tracks = Promise::Empty;\n        self.playlist_detail.playlist = Promise::Empty;\n    }\n}\n\nimpl AppState {\n    pub fn queued_entry(&self, item_id: ItemId) -> Option<QueueEntry> {\n        if let Some(queued) = self\n            .playback\n            .queue\n            .iter()\n            .find(|queued| queued.item.id() == item_id)\n            .cloned()\n        {\n            Some(queued)\n        } else {\n            self.added_queue\n                .iter()\n                .find(|queued| queued.item.id() == item_id)\n                .cloned()\n        }\n    }\n\n    pub fn add_queued_entry(&mut self, queue_entry: QueueEntry) {\n        self.added_queue.push_back(queue_entry);\n    }\n\n    pub fn loading_playback(&mut self, item: Playable, origin: PlaybackOrigin) {\n        self.common_ctx_mut().now_playing.take();\n        self.playback.state = PlaybackState::Loading;\n        self.playback.now_playing.replace(NowPlaying {\n            item,\n            origin,\n            progress: Duration::default(),\n            library: Arc::clone(&self.library),\n        });\n    }\n\n    pub fn start_playback(&mut self, item: Playable, origin: PlaybackOrigin, progress: Duration) {\n        self.common_ctx_mut().now_playing.replace(item.clone());\n        self.playback.state = PlaybackState::Playing;\n        self.playback.now_playing.replace(NowPlaying {\n            item,\n            origin,\n            progress,\n            library: Arc::clone(&self.library),\n        });\n    }\n\n    pub fn progress_playback(&mut self, progress: Duration) {\n        if let Some(now_playing) = &mut self.playback.now_playing {\n            now_playing.progress = progress;\n        }\n    }\n\n    pub fn pause_playback(&mut self) {\n        self.playback.state = PlaybackState::Paused;\n    }\n\n    pub fn resume_playback(&mut self) {\n        self.playback.state = PlaybackState::Playing;\n    }\n\n    pub fn block_playback(&mut self) {\n        // TODO: Figure out how to signal blocked playback properly.\n    }\n\n    pub fn stop_playback(&mut self) {\n        self.playback.state = PlaybackState::Stopped;\n        self.playback.now_playing.take();\n        self.common_ctx_mut().now_playing.take();\n    }\n\n    pub fn set_queue_behavior(&mut self, queue_behavior: QueueBehavior) {\n        self.playback.queue_behavior = queue_behavior;\n        self.config.queue_behavior = queue_behavior;\n        self.config.save();\n    }\n}\n\nimpl AppState {\n    pub fn common_ctx_mut(&mut self) -> &mut CommonCtx {\n        Arc::make_mut(&mut self.common_ctx)\n    }\n\n    pub fn with_library_mut(&mut self, func: impl FnOnce(&mut Library)) {\n        func(Arc::make_mut(&mut self.library));\n        self.library_updated();\n    }\n\n    fn library_updated(&mut self) {\n        if let Some(now_playing) = &mut self.playback.now_playing {\n            now_playing.library = Arc::clone(&self.library);\n        }\n        self.common_ctx_mut().library = Arc::clone(&self.library);\n    }\n}\n\nimpl AppState {\n    pub fn add_alert(&mut self, message: impl Display, style: AlertStyle) {\n        let alert = Alert {\n            message: message.to_string().into(),\n            style,\n            id: Alert::fresh_id(),\n            created_at: Instant::now(),\n        };\n        self.alerts.push_back(alert);\n    }\n\n    pub fn info_alert(&mut self, message: impl Display) {\n        self.add_alert(message, AlertStyle::Info);\n    }\n\n    pub fn error_alert(&mut self, message: impl Display) {\n        self.add_alert(message, AlertStyle::Error);\n    }\n\n    pub fn dismiss_alert(&mut self, id: usize) {\n        self.alerts.retain(|a| a.id != id);\n    }\n\n    pub fn cleanup_alerts(&mut self) {\n        let now = Instant::now();\n        self.alerts\n            .retain(|alert| now.duration_since(alert.created_at) < ALERT_DURATION);\n    }\n}\n\n#[derive(Clone, Data, Lens)]\npub struct Library {\n    pub user_profile: Promise<UserProfile>,\n    pub playlists: Promise<Vector<Playlist>>,\n    pub saved_albums: Promise<SavedAlbums>,\n    pub saved_tracks: Promise<SavedTracks>,\n    pub saved_shows: Promise<Shows>,\n}\n\nimpl Library {\n    pub fn add_track(&mut self, track: Arc<Track>) {\n        if let Some(saved) = self.saved_tracks.resolved_mut() {\n            saved.set.insert(track.id);\n            saved.tracks.push_front(track);\n        }\n    }\n\n    pub fn remove_track(&mut self, track_id: &TrackId) {\n        if let Some(saved) = self.saved_tracks.resolved_mut() {\n            saved.set.remove(track_id);\n            saved.tracks.retain(|t| &t.id != track_id);\n        }\n    }\n\n    pub fn contains_track(&self, track: &Track) -> bool {\n        if let Some(saved) = self.saved_tracks.resolved() {\n            saved.set.contains(&track.id)\n        } else {\n            false\n        }\n    }\n\n    pub fn add_album(&mut self, album: Arc<Album>) {\n        if let Some(saved) = self.saved_albums.resolved_mut() {\n            saved.set.insert(album.id.clone());\n            saved.albums.push_front(album);\n        }\n    }\n\n    pub fn remove_album(&mut self, album_id: &str) {\n        if let Some(saved) = self.saved_albums.resolved_mut() {\n            saved.set.remove(album_id);\n            saved.albums.retain(|a| a.id.as_ref() != album_id);\n        }\n    }\n\n    pub fn contains_album(&self, album: &Album) -> bool {\n        if let Some(saved) = self.saved_albums.resolved() {\n            saved.set.contains(&album.id)\n        } else {\n            false\n        }\n    }\n\n    pub fn add_show(&mut self, show: Arc<Show>) {\n        if let Some(saved) = self.saved_shows.resolved_mut() {\n            saved.set.insert(show.id.clone());\n            saved.shows.push_front(show);\n        }\n    }\n\n    pub fn remove_show(&mut self, show_id: &str) {\n        if let Some(saved) = self.saved_shows.resolved_mut() {\n            saved.set.remove(show_id);\n            saved.shows.retain(|a| a.id.as_ref() != show_id);\n        }\n    }\n\n    pub fn contains_show(&self, show: &Show) -> bool {\n        if let Some(saved) = self.saved_shows.resolved() {\n            saved.set.contains(&show.id)\n        } else {\n            false\n        }\n    }\n\n    pub fn writable_playlists(&self) -> Vec<&Playlist> {\n        if let Some(saved) = self.playlists.resolved() {\n            saved\n                .iter()\n                .filter(|playlist| {\n                    self.user_profile\n                        .resolved()\n                        .map(|user| playlist.owner.id == user.id)\n                        .unwrap_or(false)\n                        || playlist.collaborative\n                })\n                .collect()\n        } else {\n            Vec::new()\n        }\n    }\n\n    pub fn add_playlist(&mut self, playlist: Playlist) {\n        if let Some(playlists) = self.playlists.resolved_mut() {\n            playlists.push_back(playlist);\n        }\n    }\n\n    pub fn remove_from_playlist(&mut self, id: &str) {\n        if let Some(playlists) = self.playlists.resolved_mut() {\n            playlists.retain(|p| p.id.as_ref() != id);\n        }\n    }\n\n    pub fn rename_playlist(&mut self, link: PlaylistLink) {\n        if let Some(saved) = self.playlists.resolved_mut() {\n            for playlist in saved.iter_mut() {\n                if playlist.id == link.id {\n                    playlist.name = link.name;\n                    break;\n                }\n            }\n        }\n    }\n\n    pub fn is_created_by_user(&self, playlist: &Playlist) -> bool {\n        if let Some(profile) = self.user_profile.resolved() {\n            profile.id == playlist.owner.id\n        } else {\n            false\n        }\n    }\n\n    pub fn contains_playlist(&self, playlist: &Playlist) -> bool {\n        if let Some(playlists) = self.playlists.resolved() {\n            playlists.iter().any(|p| p.id == playlist.id)\n        } else {\n            false\n        }\n    }\n\n    pub fn increment_playlist_track_count(&mut self, link: &PlaylistLink) {\n        if let Some(saved) = self.playlists.resolved_mut() {\n            if let Some(playlist) = saved.iter_mut().find(|p| p.id == link.id) {\n                playlist.track_count = playlist.track_count.map(|count| count + 1);\n            }\n        }\n    }\n\n    pub fn decrement_playlist_track_count(&mut self, link: &PlaylistLink) {\n        if let Some(saved) = self.playlists.resolved_mut() {\n            if let Some(playlist) = saved.iter_mut().find(|p| p.id == link.id) {\n                playlist.track_count = playlist.track_count.map(|count| count.saturating_sub(1));\n            }\n        }\n    }\n}\n\nimpl Default for Library {\n    fn default() -> Self {\n        Library {\n            user_profile: Promise::Empty,\n            playlists: Promise::Empty,\n            saved_albums: Promise::Empty,\n            saved_tracks: Promise::Empty,\n            saved_shows: Promise::Empty,\n        }\n    }\n}\n\n#[derive(Clone, Default, Data, Lens)]\npub struct SavedTracks {\n    pub tracks: Vector<Arc<Track>>,\n    pub set: HashSet<TrackId>,\n}\n\nimpl SavedTracks {\n    pub fn new(tracks: Vector<Arc<Track>>) -> Self {\n        let set = tracks.iter().map(|t| t.id).collect();\n        Self { tracks, set }\n    }\n}\n\n#[derive(Clone, Default, Data, Lens)]\npub struct SavedAlbums {\n    pub albums: Vector<Arc<Album>>,\n    pub set: HashSet<Arc<str>>,\n}\n\nimpl SavedAlbums {\n    pub fn new(albums: Vector<Arc<Album>>) -> Self {\n        let set = albums.iter().map(|a| a.id.clone()).collect();\n        Self { albums, set }\n    }\n}\n\n#[derive(Clone, Default, Data, Lens)]\npub struct Shows {\n    pub shows: Vector<Arc<Show>>,\n    pub set: HashSet<Arc<str>>,\n}\n\nimpl Shows {\n    pub fn new(shows: Vector<Arc<Show>>) -> Self {\n        let set = shows.iter().map(|a| a.id.clone()).collect();\n        Self { shows, set }\n    }\n}\n\n#[derive(Clone, Data)]\npub struct CommonCtx {\n    pub now_playing: Option<Playable>,\n    pub library: Arc<Library>,\n    pub show_track_cover: bool,\n    pub nav: Nav,\n}\n\nimpl CommonCtx {\n    pub fn is_playing(&self, item: &Playable) -> bool {\n        matches!(&self.now_playing, Some(i) if i.same(item))\n    }\n}\n\npub type WithCtx<T> = Ctx<Arc<CommonCtx>, T>;\n\n#[derive(Clone, Data, Lens)]\npub struct HomeDetail {\n    pub made_for_you: Promise<MixedView>,\n    pub user_top_mixes: Promise<MixedView>,\n    pub best_of_artists: Promise<MixedView>,\n    pub recommended_stations: Promise<MixedView>,\n    pub uniquely_yours: Promise<MixedView>,\n    pub your_shows: Promise<MixedView>,\n    pub shows_that_you_might_like: Promise<MixedView>,\n    pub jump_back_in: Promise<MixedView>,\n    pub user_top_tracks: Promise<Vector<Arc<Track>>>,\n    pub user_top_artists: Promise<Vector<Artist>>,\n}\n\n#[derive(Clone, Data, Lens)]\npub struct MixedView {\n    pub title: Arc<str>,\n    pub playlists: Vector<Playlist>,\n    pub artists: Vector<Artist>,\n    pub albums: Vector<Arc<Album>>,\n    pub shows: Vector<Arc<Show>>,\n}\n\nstatic ALERT_ID: AtomicUsize = AtomicUsize::new(0);\n\n#[derive(Clone, Data, Lens)]\npub struct Alert {\n    pub id: usize,\n    pub message: Arc<str>,\n    pub style: AlertStyle,\n    pub created_at: Instant,\n}\n\nimpl Alert {\n    fn fresh_id() -> usize {\n        ALERT_ID.fetch_add(1, Ordering::SeqCst)\n    }\n}\n\n#[derive(Clone, Data, Eq, PartialEq)]\npub enum AlertStyle {\n    Error,\n    Info,\n}\n"
  },
  {
    "path": "psst-gui/src/data/nav.rs",
    "content": "use std::sync::Arc;\n\nuse druid::Data;\nuse serde::{Deserialize, Serialize};\nuse url::Url;\n\nuse crate::data::track::TrackId;\nuse crate::data::{AlbumLink, ArtistLink, PlaylistLink, ShowLink};\n\nuse super::RecommendationsRequest;\n\n#[derive(Copy, Clone, Debug, Data, PartialEq, Eq, Hash)]\npub enum Route {\n    Home,\n    Lyrics,\n    SavedTracks,\n    SavedAlbums,\n    Shows,\n    SearchResults,\n    ArtistDetail,\n    AlbumDetail,\n    ShowDetail,\n    PlaylistDetail,\n    Recommendations,\n}\n\n#[derive(Clone, Debug, Data, PartialEq, Eq, Default, Serialize, Deserialize)]\npub enum Nav {\n    #[default]\n    Home,\n    Lyrics,\n    SavedTracks,\n    SavedAlbums,\n    Shows,\n    SearchResults(Arc<str>),\n    AlbumDetail(AlbumLink, Option<TrackId>),\n    ArtistDetail(ArtistLink),\n    PlaylistDetail(PlaylistLink),\n    ShowDetail(ShowLink),\n    Recommendations(Arc<RecommendationsRequest>),\n}\n\nimpl Nav {\n    pub fn route(&self) -> Route {\n        match self {\n            Nav::Home => Route::Home,\n            Nav::Lyrics => Route::Lyrics,\n            Nav::SavedTracks => Route::SavedTracks,\n            Nav::SavedAlbums => Route::SavedAlbums,\n            Nav::Shows => Route::Shows,\n            Nav::SearchResults(_) => Route::SearchResults,\n            Nav::AlbumDetail(_, _) => Route::AlbumDetail,\n            Nav::ArtistDetail(_) => Route::ArtistDetail,\n            Nav::PlaylistDetail(_) => Route::PlaylistDetail,\n            Nav::ShowDetail(_) => Route::ShowDetail,\n            Nav::Recommendations(_) => Route::Recommendations,\n        }\n    }\n\n    pub fn title(&self) -> String {\n        match self {\n            Nav::Home => \"Home\".to_string(),\n            Nav::Lyrics => \"Lyrics\".to_string(),\n            Nav::SavedTracks => \"Saved Tracks\".to_string(),\n            Nav::SavedAlbums => \"Saved Albums\".to_string(),\n            Nav::Shows => \"Podcasts\".to_string(),\n            Nav::SearchResults(query) => query.to_string(),\n            Nav::AlbumDetail(link, _) => link.name.to_string(),\n            Nav::ArtistDetail(link) => link.name.to_string(),\n            Nav::PlaylistDetail(link) => link.name.to_string(),\n            Nav::ShowDetail(link) => link.name.to_string(),\n            Nav::Recommendations(_) => \"Recommended\".to_string(),\n        }\n    }\n\n    pub fn full_title(&self) -> String {\n        match self {\n            Nav::Home => \"Home\".to_string(),\n            Nav::Lyrics => \"Lyrics\".to_string(),\n            Nav::SavedTracks => \"Saved Tracks\".to_string(),\n            Nav::SavedAlbums => \"Saved Albums\".to_string(),\n            Nav::Shows => \"Saved Shows\".to_string(),\n            Nav::SearchResults(query) => format!(\"Search \\\"{query}\\\"\"),\n            Nav::AlbumDetail(link, _) => format!(\"Album \\\"{}\\\"\", link.name),\n            Nav::ArtistDetail(link) => format!(\"Artist \\\"{}\\\"\", link.name),\n            Nav::PlaylistDetail(link) => format!(\"Playlist \\\"{}\\\"\", link.name),\n            Nav::ShowDetail(link) => format!(\"Show \\\"{}\\\"\", link.name),\n            Nav::Recommendations(_) => \"Recommended\".to_string(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Data, Eq, PartialEq, Hash)]\npub enum SpotifyUrl {\n    Playlist(Arc<str>),\n    Artist(Arc<str>),\n    Album(Arc<str>),\n    Track(Arc<str>),\n    Show(Arc<str>),\n}\n\nimpl SpotifyUrl {\n    pub fn parse(url: &str) -> Option<Self> {\n        let url = Url::parse(url).ok()?;\n        let mut segments = url.path_segments()?;\n        let entity = segments.next()?;\n        let id = segments.next()?;\n        match entity {\n            \"playlist\" => Some(Self::Playlist(id.into())),\n            \"artist\" => Some(Self::Artist(id.into())),\n            \"album\" => Some(Self::Album(id.into())),\n            \"track\" => Some(Self::Track(id.into())),\n            \"show\" => Some(Self::Show(id.into())),\n            _ => None,\n        }\n    }\n\n    pub fn id(&self) -> Arc<str> {\n        match self {\n            SpotifyUrl::Playlist(id) => id.clone(),\n            SpotifyUrl::Artist(id) => id.clone(),\n            SpotifyUrl::Album(id) => id.clone(),\n            SpotifyUrl::Track(id) => id.clone(),\n            SpotifyUrl::Show(id) => id.clone(),\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/playback.rs",
    "content": "use std::{fmt, sync::Arc, time::Duration};\n\nuse druid::{im::Vector, Data, Lens};\nuse druid_enums::Matcher;\nuse psst_core::item_id::ItemId;\nuse serde::{Deserialize, Serialize};\n\nuse super::{\n    AlbumLink, ArtistLink, Episode, Library, Nav, PlaylistLink, RecommendationsRequest, ShowLink,\n    Track,\n};\n\n#[derive(Clone, Data, Lens)]\npub struct Playback {\n    pub state: PlaybackState,\n    pub now_playing: Option<NowPlaying>,\n    pub queue_behavior: QueueBehavior,\n    pub queue: Vector<QueueEntry>,\n    pub volume: f64,\n}\n\n#[derive(Clone, Debug, Data, Lens)]\npub struct QueueEntry {\n    pub item: Playable,\n    pub origin: PlaybackOrigin,\n}\n\n#[derive(Clone, Debug, Matcher)]\npub enum Playable {\n    Track(Arc<Track>),\n    Episode(Arc<Episode>),\n}\n\nimpl Playable {\n    pub fn track(&self) -> Option<&Arc<Track>> {\n        if let Self::Track(track) = self {\n            Some(track)\n        } else {\n            None\n        }\n    }\n\n    pub fn id(&self) -> ItemId {\n        match self {\n            Playable::Track(track) => track.id.0,\n            Playable::Episode(episode) => episode.id.0,\n        }\n    }\n\n    pub fn name(&self) -> &Arc<str> {\n        match self {\n            Playable::Track(track) => &track.name,\n            Playable::Episode(episode) => &episode.name,\n        }\n    }\n\n    pub fn duration(&self) -> Duration {\n        match self {\n            Playable::Track(track) => track.duration,\n            Playable::Episode(episode) => episode.duration,\n        }\n    }\n\n    pub fn same(&self, other: &Self) -> bool {\n        self.id() == other.id()\n    }\n}\n\nimpl Data for Playable {\n    fn same(&self, other: &Self) -> bool {\n        self.same(other)\n    }\n}\n\n#[derive(Default, Copy, Clone, Debug, Data, Eq, PartialEq, Serialize, Deserialize)]\npub enum QueueBehavior {\n    #[default]\n    Sequential,\n    Random,\n    LoopTrack,\n    LoopAll,\n}\n\n#[derive(Copy, Clone, Debug, Data, Eq, PartialEq)]\npub enum PlaybackState {\n    Loading,\n    Playing,\n    Paused,\n    Stopped,\n}\n\n#[derive(Clone, Data, Lens)]\npub struct NowPlaying {\n    pub item: Playable,\n    pub origin: PlaybackOrigin,\n    pub progress: Duration,\n\n    // Although keeping a ref to the `Library` here is a bit of a hack, it dramatically\n    // simplifies displaying the track context menu in the playback bar.\n    pub library: Arc<Library>,\n}\n\nimpl NowPlaying {\n    pub fn cover_image_url(&self, width: f64, height: f64) -> Option<&str> {\n        match &self.item {\n            Playable::Track(track) => {\n                let album = track.album.as_ref().or(match &self.origin {\n                    PlaybackOrigin::Album(album) => Some(album),\n                    _ => None,\n                })?;\n                Some(&album.image(width, height)?.url)\n            }\n            Playable::Episode(episode) => Some(&episode.image(width, height)?.url),\n        }\n    }\n\n    pub fn cover_image_metadata(&self) -> Option<(&str, (u32, u32))> {\n        match &self.item {\n            Playable::Track(track) => track.album.as_ref().and_then(|album| {\n                album.images.get(0).map(|img| {\n                    (\n                        &*img.url,\n                        (\n                            img.width.unwrap_or(0) as u32,\n                            img.height.unwrap_or(0) as u32,\n                        ),\n                    )\n                })\n            }),\n            Playable::Episode(episode) => episode.images.get(0).map(|img| {\n                (\n                    &*img.url,\n                    (\n                        img.width.unwrap_or(0) as u32,\n                        img.height.unwrap_or(0) as u32,\n                    ),\n                )\n            }),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Data)]\npub enum PlaybackOrigin {\n    Home,\n    Library,\n    Album(AlbumLink),\n    Artist(ArtistLink),\n    Playlist(PlaylistLink),\n    Show(ShowLink),\n    Search(Arc<str>),\n    Recommendations(Arc<RecommendationsRequest>),\n}\n\nimpl PlaybackOrigin {\n    pub fn to_nav(&self) -> Nav {\n        match &self {\n            PlaybackOrigin::Home => Nav::Home,\n            PlaybackOrigin::Library => Nav::SavedTracks,\n            PlaybackOrigin::Album(link) => Nav::AlbumDetail(link.clone(), None),\n            PlaybackOrigin::Artist(link) => Nav::ArtistDetail(link.clone()),\n            PlaybackOrigin::Playlist(link) => Nav::PlaylistDetail(link.clone()),\n            PlaybackOrigin::Show(link) => Nav::ShowDetail(link.clone()),\n            PlaybackOrigin::Search(query) => Nav::SearchResults(query.clone()),\n            PlaybackOrigin::Recommendations(request) => Nav::Recommendations(request.clone()),\n        }\n    }\n}\n\nimpl fmt::Display for PlaybackOrigin {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match &self {\n            PlaybackOrigin::Home => f.write_str(\"Home\"),\n            PlaybackOrigin::Library => f.write_str(\"Saved Tracks\"),\n            PlaybackOrigin::Album(link) => link.name.fmt(f),\n            PlaybackOrigin::Artist(link) => link.name.fmt(f),\n            PlaybackOrigin::Playlist(link) => link.name.fmt(f),\n            PlaybackOrigin::Show(link) => link.name.fmt(f),\n            PlaybackOrigin::Search(query) => query.fmt(f),\n            PlaybackOrigin::Recommendations(_) => f.write_str(\"Recommended\"),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Data)]\npub struct PlaybackPayload {\n    pub origin: PlaybackOrigin,\n    pub items: Vector<Playable>,\n    pub position: usize,\n}\n"
  },
  {
    "path": "psst-gui/src/data/playlist.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{im::Vector, Data, Lens};\nuse serde::{Deserialize, Deserializer, Serialize};\n\nuse crate::data::utils::sanitize_html_string;\nuse crate::data::{user::PublicUser, Image, Promise, Track, TrackId};\n\n#[derive(Clone, Debug, Data, Lens)]\npub struct PlaylistDetail {\n    pub playlist: Promise<Playlist, PlaylistLink>,\n    pub tracks: Promise<PlaylistTracks, PlaylistLink>,\n}\n\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\npub struct PlaylistAddTrack {\n    pub link: PlaylistLink,\n    pub track_id: TrackId,\n}\n\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\npub struct PlaylistRemoveTrack {\n    pub link: PlaylistLink,\n    pub track_pos: usize,\n}\n\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\npub struct Playlist {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub images: Option<Vector<Image>>,\n    #[serde(deserialize_with = \"deserialize_description\")]\n    pub description: Arc<str>,\n    #[serde(rename = \"tracks\")]\n    #[serde(deserialize_with = \"deserialize_track_count\")]\n    pub track_count: Option<usize>,\n    pub owner: PublicUser,\n    pub collaborative: bool,\n    #[serde(rename = \"public\")]\n    pub public: Option<bool>,\n}\n\nimpl Playlist {\n    pub fn link(&self) -> PlaylistLink {\n        PlaylistLink {\n            id: self.id.clone(),\n            name: self.name.clone(),\n        }\n    }\n\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\n        self.images\n            .as_ref()\n            .and_then(|images| Image::at_least_of_size(images, width, height))\n    }\n\n    pub fn url(&self) -> String {\n        format!(\"https://open.spotify.com/playlist/{id}\", id = self.id)\n    }\n}\n\n#[derive(Clone, Debug, Data, Lens)]\npub struct PlaylistTracks {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n    pub tracks: Vector<Arc<Track>>,\n}\n\nimpl PlaylistTracks {\n    pub fn link(&self) -> PlaylistLink {\n        PlaylistLink {\n            id: self.id.clone(),\n            name: self.name.clone(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)]\npub struct PlaylistLink {\n    pub id: Arc<str>,\n    pub name: Arc<str>,\n}\n\nfn deserialize_track_count<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    #[derive(Deserialize)]\n    struct PlaylistTracksRef {\n        total: Option<usize>,\n    }\n\n    Ok(PlaylistTracksRef::deserialize(deserializer)?.total)\n}\n\nfn deserialize_description<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let description: String = String::deserialize(deserializer)?;\n    Ok(sanitize_html_string(&description))\n}\n"
  },
  {
    "path": "psst-gui/src/data/promise.rs",
    "content": "use druid::Data;\n\nuse crate::error::Error;\n\n#[derive(Clone, Debug, Data, Default)]\npub enum Promise<T: Data, D: Data = (), E: Data = Error> {\n    #[default]\n    Empty,\n    Deferred { def: D },\n    Resolved { def: D, val: T },\n    Rejected { def: D, err: E },\n}\n\n#[derive(Eq, PartialEq, Debug)]\npub enum PromiseState {\n    Empty,\n    Deferred,\n    Resolved,\n    Rejected,\n}\n\nimpl<T: Data, D: Data, E: Data> Promise<T, D, E> {\n    pub fn state(&self) -> PromiseState {\n        match self {\n            Self::Empty => PromiseState::Empty,\n            Self::Deferred { .. } => PromiseState::Deferred,\n            Self::Resolved { .. } => PromiseState::Resolved,\n            Self::Rejected { .. } => PromiseState::Rejected,\n        }\n    }\n\n    pub fn is_resolved(&self) -> bool {\n        self.state() == PromiseState::Resolved\n    }\n\n    pub fn is_deferred(&self, d: &D) -> bool\n    where\n        D: PartialEq,\n    {\n        matches!(self, Self::Deferred { def } if def == d)\n    }\n\n    pub fn contains(&self, d: &D) -> bool\n    where\n        D: PartialEq,\n    {\n        matches!(self, Self::Resolved { def, .. } if def == d)\n    }\n\n    pub fn deferred(&self) -> Option<&D> {\n        match self {\n            Promise::Deferred { def }\n            | Promise::Resolved { def, .. }\n            | Promise::Rejected { def, .. } => Some(def),\n            Promise::Empty => None,\n        }\n    }\n\n    pub fn resolved(&self) -> Option<&T> {\n        if let Promise::Resolved { val, .. } = self {\n            Some(val)\n        } else {\n            None\n        }\n    }\n\n    pub fn resolved_mut(&mut self) -> Option<&mut T> {\n        if let Promise::Resolved { val, .. } = self {\n            Some(val)\n        } else {\n            None\n        }\n    }\n\n    pub fn clear(&mut self) {\n        *self = Self::Empty;\n    }\n\n    pub fn defer(&mut self, def: D) {\n        *self = Self::Deferred { def };\n    }\n\n    pub fn resolve(&mut self, def: D, val: T) {\n        *self = Self::Resolved { def, val };\n    }\n\n    pub fn reject(&mut self, def: D, err: E) {\n        *self = Self::Rejected { def, err };\n    }\n\n    pub fn resolve_or_reject(&mut self, def: D, res: Result<T, E>) {\n        match res {\n            Ok(val) => self.resolve(def, val),\n            Err(err) => self.reject(def, err),\n        }\n    }\n\n    pub fn update(&mut self, (def, res): (D, Result<T, E>))\n    where\n        D: PartialEq,\n    {\n        if self.is_deferred(&def) {\n            self.resolve_or_reject(def, res);\n        } else {\n            // Ignore.\n        }\n    }\n}\n\nimpl<D: Data + Default, T: Data, E: Data> Promise<T, D, E> {\n    pub fn defer_default(&mut self) {\n        *self = Self::Deferred { def: D::default() };\n    }\n}\n\n\n"
  },
  {
    "path": "psst-gui/src/data/recommend.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    im::{vector, Vector},\n    Data, Lens,\n};\nuse serde::{Deserialize, Serialize};\n\nuse super::{ArtistLink, Float64, Promise, Track, TrackId};\n\n#[derive(Clone, Data, Lens)]\npub struct Recommend {\n    pub knobs: Arc<RecommendationsKnobs>,\n    pub results: Promise<Recommendations, Arc<RecommendationsRequest>>,\n}\n\n#[derive(Clone, Debug, Default, Data, PartialEq, Eq, Hash, Deserialize, Serialize)]\npub struct RecommendationsRequest {\n    pub seed_artists: Vector<ArtistLink>,\n    pub seed_tracks: Vector<TrackId>,\n    #[serde(skip)]\n    pub params: RecommendationsParams,\n}\n\nimpl RecommendationsRequest {\n    pub fn for_track(id: TrackId) -> Self {\n        Self {\n            seed_tracks: vector![id],\n            ..Self::default()\n        }\n    }\n\n    pub fn with_params(mut self, params: RecommendationsParams) -> Self {\n        self.params = params;\n        self\n    }\n}\n\n#[derive(Clone, Debug, Default, Data, Lens)]\npub struct RecommendationsKnobs {\n    pub duration_ms: Toggled<u64>,\n    pub popularity: Toggled<u64>,\n    pub key: Toggled<u64>,\n    pub mode: Toggled<u64>,\n    pub tempo: Toggled<u64>,\n    pub time_signature: Toggled<u64>,\n\n    pub acousticness: Toggled<f64>,\n    pub danceability: Toggled<f64>,\n    pub energy: Toggled<f64>,\n    pub instrumentalness: Toggled<f64>,\n    pub liveness: Toggled<f64>,\n    pub loudness: Toggled<f64>,\n    pub speechiness: Toggled<f64>,\n    pub valence: Toggled<f64>,\n}\n\nimpl RecommendationsKnobs {\n    pub fn as_params(&self) -> RecommendationsParams {\n        RecommendationsParams {\n            duration_ms: Range::new(None, None, self.duration_ms.into()),\n            popularity: Range::new(None, None, self.popularity.into()),\n            key: Range::new(None, None, self.key.into()),\n            mode: Range::new(None, None, self.mode.into()),\n            tempo: Range::new(None, None, self.tempo.into()),\n            time_signature: Range::new(None, None, self.time_signature.into()),\n            acousticness: Range::new(None, None, self.acousticness.into()),\n            danceability: Range::new(None, None, self.danceability.into()),\n            energy: Range::new(None, None, self.energy.into()),\n            instrumentalness: Range::new(None, None, self.instrumentalness.into()),\n            liveness: Range::new(None, None, self.liveness.into()),\n            loudness: Range::new(None, None, self.loudness.into()),\n            speechiness: Range::new(None, None, self.speechiness.into()),\n            valence: Range::new(None, None, self.valence.into()),\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, Default, Data, Lens)]\npub struct Toggled<T> {\n    pub enabled: bool,\n    pub value: T,\n}\n\nimpl From<Toggled<u64>> for Option<u64> {\n    fn from(t: Toggled<u64>) -> Self {\n        if t.enabled {\n            Some(t.value)\n        } else {\n            None\n        }\n    }\n}\n\nimpl From<Toggled<f64>> for Option<Float64> {\n    fn from(t: Toggled<f64>) -> Self {\n        if t.enabled {\n            Some(t.value.into())\n        } else {\n            None\n        }\n    }\n}\n\n#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Data, Lens)]\npub struct RecommendationsParams {\n    pub duration_ms: Range<u64>,\n    pub popularity: Range<u64>,\n    pub key: Range<u64>,\n    pub mode: Range<u64>,\n    pub tempo: Range<u64>,\n    pub time_signature: Range<u64>,\n\n    pub acousticness: Range<Float64>,\n    pub danceability: Range<Float64>,\n    pub energy: Range<Float64>,\n    pub instrumentalness: Range<Float64>,\n    pub liveness: Range<Float64>,\n    pub loudness: Range<Float64>,\n    pub speechiness: Range<Float64>,\n    pub valence: Range<Float64>,\n}\n\n#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Data, Lens)]\npub struct Range<T> {\n    pub min: Option<T>,\n    pub max: Option<T>,\n    pub target: Option<T>,\n}\n\nimpl<T> Range<T> {\n    pub fn new(min: Option<T>, max: Option<T>, target: Option<T>) -> Self {\n        Self { min, max, target }\n    }\n}\n\n#[derive(Clone, Data, Deserialize, Lens)]\npub struct Recommendations {\n    #[serde(skip)]\n    pub request: Arc<RecommendationsRequest>,\n    pub seeds: Vector<RecommendationsSeed>,\n    pub tracks: Vector<Arc<Track>>,\n}\n\n#[derive(Clone, Data, Deserialize, Lens)]\npub struct RecommendationsSeed {\n    #[serde(default)]\n    pub after_filtering_size: usize,\n    #[serde(default)]\n    pub after_relinking_size: usize,\n    pub href: Option<Arc<str>>,\n    pub id: Arc<str>,\n    #[serde(default)]\n    pub initial_pool_size: usize,\n    #[serde(rename = \"type\")]\n    pub _type: RecommendationsSeedType,\n}\n\n#[derive(Clone, Data, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\npub enum RecommendationsSeedType {\n    Artist,\n    Track,\n    Genre,\n}\n"
  },
  {
    "path": "psst-gui/src/data/search.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{im::Vector, Data, Lens};\n\nuse crate::data::{Album, Artist, Playlist, Promise, Show, Track};\n\n#[derive(Clone, Data, Lens)]\npub struct Search {\n    pub input: String,\n    pub topic: Option<SearchTopic>,\n    pub results: Promise<SearchResults, (Arc<str>, Option<SearchTopic>)>,\n}\n\n#[derive(Copy, Clone, Data, Eq, PartialEq)]\npub enum SearchTopic {\n    Artist,\n    Album,\n    Track,\n    Playlist,\n    Show,\n}\n\nimpl SearchTopic {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            SearchTopic::Artist => \"artist\",\n            SearchTopic::Album => \"album\",\n            SearchTopic::Track => \"track\",\n            SearchTopic::Playlist => \"playlist\",\n            SearchTopic::Show => \"show\",\n        }\n    }\n\n    pub fn display_name(&self) -> &'static str {\n        match self {\n            SearchTopic::Artist => \"Artists\",\n            SearchTopic::Album => \"Albums\",\n            SearchTopic::Track => \"Tracks\",\n            SearchTopic::Playlist => \"Playlists\",\n            SearchTopic::Show => \"Podcasts\",\n        }\n    }\n\n    pub fn all() -> &'static [Self] {\n        &[\n            Self::Artist,\n            Self::Album,\n            Self::Track,\n            Self::Playlist,\n            Self::Show,\n        ]\n    }\n}\n\n#[derive(Clone, Data, Lens)]\npub struct SearchResults {\n    pub query: Arc<str>,\n    pub topic: Option<SearchTopic>,\n    pub artists: Vector<Artist>,\n    pub albums: Vector<Arc<Album>>,\n    pub tracks: Vector<Arc<Track>>,\n    pub playlists: Vector<Playlist>,\n    pub shows: Vector<Arc<Show>>,\n}\n\nimpl SearchResults {\n    pub fn is_empty(&self) -> bool {\n        self.artists.is_empty()\n            && self.albums.is_empty()\n            && self.tracks.is_empty()\n            && self.playlists.is_empty()\n            && self.shows.is_empty()\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/show.rs",
    "content": "use std::{convert::TryFrom, sync::Arc, time::Duration};\r\n\r\nuse druid::{im::Vector, Data, Lens};\r\nuse psst_core::item_id::{ItemId, ItemIdType};\r\nuse serde::{Deserialize, Serialize};\r\nuse time::{macros::format_description, Date};\r\n\r\nuse crate::data::{Image, Promise};\r\n\r\nuse super::album::DatePrecision;\r\n\r\n#[derive(Clone, Data, Lens)]\r\npub struct ShowDetail {\r\n    pub show: Promise<Arc<Show>, ShowLink>,\r\n    pub episodes: Promise<ShowEpisodes, ShowLink>,\r\n}\r\n\r\n#[derive(Clone, Data, Lens, Deserialize)]\r\npub struct Show {\r\n    pub id: Arc<str>,\r\n    pub name: Arc<str>,\r\n    pub images: Vector<Image>,\r\n    pub publisher: Arc<str>,\r\n    pub description: Arc<str>,\r\n    pub total_episodes: Option<usize>,\r\n}\r\n\r\nimpl Show {\r\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\r\n        Image::at_least_of_size(&self.images, width, height)\r\n    }\r\n\r\n    pub fn link(&self) -> ShowLink {\r\n        ShowLink {\r\n            id: self.id.clone(),\r\n            name: self.name.clone(),\r\n        }\r\n    }\r\n}\r\n\r\n#[derive(Clone, Data, Lens)]\r\npub struct ShowEpisodes {\r\n    pub show: ShowLink,\r\n    pub episodes: Vector<Arc<Episode>>,\r\n}\r\n\r\n#[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)]\r\npub struct ShowLink {\r\n    pub id: Arc<str>,\r\n    pub name: Arc<str>,\r\n}\r\n\r\nimpl ShowLink {\r\n    pub fn url(&self) -> String {\r\n        format!(\"https://open.spotify.com/show/{id}\", id = self.id)\r\n    }\r\n}\r\n\r\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\r\npub struct Episode {\r\n    pub id: EpisodeId,\r\n    pub name: Arc<str>,\r\n    pub show: ShowLink,\r\n    pub images: Vector<Image>,\r\n    pub description: Arc<str>,\r\n    pub languages: Vector<Arc<str>>,\r\n    #[serde(rename = \"duration_ms\")]\r\n    #[serde(deserialize_with = \"super::utils::deserialize_millis\")]\r\n    pub duration: Duration,\r\n    #[serde(deserialize_with = \"super::utils::deserialize_date_option\")]\r\n    #[data(same_fn = \"PartialEq::eq\")]\r\n    pub release_date: Option<Date>,\r\n    #[data(same_fn = \"PartialEq::eq\")]\r\n    pub release_date_precision: Option<DatePrecision>,\r\n    pub resume_point: Option<ResumePoint>,\r\n}\r\n\r\nimpl Episode {\r\n    pub fn image(&self, width: f64, height: f64) -> Option<&Image> {\r\n        Image::at_least_of_size(&self.images, width, height)\r\n    }\r\n\r\n    pub fn url(&self) -> String {\r\n        format!(\r\n            \"https://open.spotify.com/episode/{id}\",\r\n            id = self.id.0.to_base62()\r\n        )\r\n    }\r\n\r\n    pub fn release(&self) -> String {\r\n        let format = format_description!(\"[month repr:short] [day], [year]\");\r\n        self.release_date\r\n            .as_ref()\r\n            .map(|date| date.format(format).expect(\"Invalid format\"))\r\n            .unwrap_or_else(|| '-'.to_string())\r\n    }\r\n}\r\n\r\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\r\npub struct EpisodeLink {\r\n    pub id: EpisodeId,\r\n    pub name: Arc<str>,\r\n}\r\n\r\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\r\npub struct ResumePoint {\r\n    pub fully_played: bool,\r\n    #[serde(rename = \"resume_position_ms\")]\r\n    #[serde(deserialize_with = \"super::utils::deserialize_millis\")]\r\n    pub resume_position: Duration,\r\n}\r\n\r\n#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Hash, Deserialize, Serialize)]\r\n#[serde(try_from = \"String\")]\r\n#[serde(into = \"String\")]\r\npub struct EpisodeId(pub ItemId);\r\n\r\nimpl Data for EpisodeId {\r\n    fn same(&self, other: &Self) -> bool {\r\n        self.0 == other.0\r\n    }\r\n}\r\n\r\nimpl TryFrom<String> for EpisodeId {\r\n    type Error = &'static str;\r\n\r\n    fn try_from(value: String) -> Result<Self, Self::Error> {\r\n        ItemId::from_base62(&value, ItemIdType::Podcast)\r\n            .ok_or(\"Invalid ID\")\r\n            .map(Self)\r\n    }\r\n}\r\n\r\nimpl From<EpisodeId> for String {\r\n    fn from(id: EpisodeId) -> Self {\r\n        id.0.to_base62()\r\n    }\r\n}\r\n"
  },
  {
    "path": "psst-gui/src/data/slider_scroll_scale.rs",
    "content": "use std::f64;\n\nuse druid::Lens;\n\nuse {\n    druid::Data,\n    serde::{Deserialize, Serialize},\n};\n\n#[derive(Clone, Debug, Data, Lens, PartialEq, Serialize, Deserialize)]\npub struct SliderScrollScale {\n    // Volume percentage per 'bump' of the wheel(s)\n    pub scale: f64,\n    // If you have an MX Master, or another mouse with a free wheel, setting this to the\n    // number of scroll events that get fired per 'bump' of the wheel will make it\n    // change the volume at the same rate as the thumb wheel\n    pub y: f64,\n    // In case anyone wants it\n    pub x: f64,\n}\n\nimpl Default for SliderScrollScale {\n    fn default() -> Self {\n        Self {\n            scale: 3.0,\n            y: 1.0,\n            x: 1.0,\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/data/track.rs",
    "content": "use std::{convert::TryFrom, sync::Arc, time::Duration};\n\nuse druid::{im::Vector, lens::Map, Data, Lens};\nuse itertools::Itertools;\nuse psst_core::item_id::{ItemId, ItemIdType};\nuse serde::{Deserialize, Serialize};\n\nuse crate::data::{AlbumLink, ArtistLink};\n\n#[derive(Clone, Debug, Data, Lens, Deserialize)]\npub struct Track {\n    #[serde(default)]\n    pub id: TrackId,\n    pub name: Arc<str>,\n    pub album: Option<AlbumLink>,\n    pub artists: Vector<ArtistLink>,\n    #[serde(rename = \"duration_ms\")]\n    #[serde(deserialize_with = \"super::utils::deserialize_millis\")]\n    pub duration: Duration,\n    pub disc_number: usize,\n    pub track_number: usize,\n    pub explicit: bool,\n    pub is_local: bool,\n    #[serde(skip_deserializing)]\n    pub local_path: Option<Arc<str>>,\n    pub is_playable: Option<bool>,\n    pub popularity: Option<u32>,\n    #[serde(skip)]\n    pub track_pos: usize,\n    pub lyrics: Option<Arc<[TrackLines]>>,\n}\n\nimpl Track {\n    pub fn lens_artist_name() -> impl Lens<Self, Arc<str>> {\n        Map::new(\n            |track: &Self| track.artist_name(),\n            |_, _| {\n                // Immutable.\n            },\n        )\n    }\n\n    pub fn lens_album_name() -> impl Lens<Self, Arc<str>> {\n        Map::new(\n            |track: &Self| track.album_name(),\n            |_, _| {\n                // Immutable.\n            },\n        )\n    }\n\n    pub fn artist_name(&self) -> Arc<str> {\n        self.artists\n            .front()\n            .map(|artist| artist.name.clone())\n            .unwrap_or_else(|| \"Unknown\".into())\n    }\n\n    pub fn artist_names(&self) -> String {\n        self.artists\n            .iter()\n            .map(|artist| artist.name.clone())\n            .join(\", \")\n    }\n\n    pub fn album_name(&self) -> Arc<str> {\n        self.album\n            .as_ref()\n            .map(|album| album.name.clone())\n            .unwrap_or_else(|| \"Unknown\".into())\n    }\n\n    pub fn url(&self) -> String {\n        format!(\"https://open.spotify.com/track/{}\", self.id.0.to_base62())\n    }\n}\n\n#[derive(Clone, Debug, Data, Lens, Deserialize, Serialize, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\npub struct TrackLines {\n    pub start_time_ms: String,\n    pub words: String,\n    pub end_time_ms: String,\n}\n\n#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Hash, Deserialize, Serialize)]\n#[serde(try_from = \"String\")]\n#[serde(into = \"String\")]\npub struct TrackId(pub ItemId);\n\nimpl Data for TrackId {\n    fn same(&self, other: &Self) -> bool {\n        self.0 == other.0\n    }\n}\n\nimpl TryFrom<String> for TrackId {\n    type Error = &'static str;\n\n    fn try_from(value: String) -> Result<Self, Self::Error> {\n        ItemId::from_base62(&value, ItemIdType::Track)\n            .ok_or(\"Invalid ID\")\n            .map(Self)\n    }\n}\n\nimpl From<TrackId> for String {\n    fn from(id: TrackId) -> Self {\n        id.0.to_base62()\n    }\n}\n\n#[derive(Clone, Data, Debug, Deserialize)]\n#[allow(dead_code)]\npub struct AudioAnalysis {\n    pub segments: Vector<AudioSegment>,\n}\n\n#[derive(Clone, Data, Debug, Deserialize)]\n#[allow(dead_code)]\npub struct AudioSegment {\n    #[serde(flatten)]\n    pub interval: TimeInterval,\n    pub loudness_start: f64,\n    pub loudness_max: f64,\n    pub loudness_max_time: f64,\n}\n\n#[derive(Clone, Data, Debug, Deserialize)]\n#[allow(dead_code)]\npub struct TimeInterval {\n    #[serde(deserialize_with = \"super::utils::deserialize_secs\")]\n    pub start: Duration,\n    #[serde(deserialize_with = \"super::utils::deserialize_secs\")]\n    pub duration: Duration,\n    pub confidence: f64,\n}\n"
  },
  {
    "path": "psst-gui/src/data/user.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{Data, Lens};\nuse serde::Deserialize;\n\n#[derive(Clone, Data, Lens, Deserialize)]\npub struct UserProfile {\n    pub display_name: Arc<str>,\n    pub email: Arc<str>,\n    pub id: Arc<str>,\n}\n\n#[derive(Clone, Data, Lens, Deserialize, Debug)]\npub struct PublicUser {\n    pub display_name: Arc<str>,\n    pub id: Arc<str>,\n}\n"
  },
  {
    "path": "psst-gui/src/data/utils.rs",
    "content": "use std::{\n    convert::TryFrom,\n    fmt, hash,\n    sync::Arc,\n    time::{Duration, SystemTime},\n};\n\nuse druid::{im::Vector, Data, Lens};\nuse sanitize_html::rules::predefined::DEFAULT;\nuse sanitize_html::sanitize_str;\nuse serde::{Deserialize, Deserializer, Serialize};\nuse time::{Date, Month};\n\n#[derive(Clone, Data, Lens)]\npub struct Cached<T: Data> {\n    pub data: T,\n    #[data(ignore)]\n    pub cached_at: Option<SystemTime>,\n}\n\nimpl<T: Data> Cached<T> {\n    pub fn new(data: T, at: SystemTime) -> Self {\n        Self {\n            data,\n            cached_at: Some(at),\n        }\n    }\n\n    pub fn fresh(data: T) -> Self {\n        Self {\n            data,\n            cached_at: None,\n        }\n    }\n\n    pub fn map<U: Data>(self, f: impl Fn(T) -> U) -> Cached<U> {\n        Cached {\n            data: f(self.data),\n            cached_at: self.cached_at,\n        }\n    }\n}\n\n#[derive(Deserialize)]\npub struct Page<T: Clone> {\n    pub items: Vector<T>,\n    pub limit: usize,\n    pub offset: usize,\n    pub total: usize,\n}\n\n#[derive(Clone, Debug, Eq, PartialEq, Hash, Data, Deserialize, Serialize)]\npub struct Image {\n    pub url: Arc<str>,\n    pub width: Option<usize>,\n    pub height: Option<usize>,\n}\n\nimpl Image {\n    pub fn fits(&self, width: f64, height: f64) -> bool {\n        if let (Some(w), Some(h)) = (self.width, self.height) {\n            (w as f64) < width && (h as f64) < height\n        } else {\n            true // Unknown dimensions, treat as fitting.\n        }\n    }\n\n    pub fn at_least_of_size(images: &Vector<Self>, width: f64, height: f64) -> Option<&Self> {\n        images\n            .iter()\n            .rev()\n            .find(|img| !img.fits(width, height))\n            .or_else(|| images.back())\n    }\n}\n\npub fn default_str() -> Arc<str> {\n    \"\".into()\n}\n\n#[derive(Copy, Clone, Default, Debug, Data, Deserialize)]\npub struct Float64(pub f64);\n\nimpl PartialEq for Float64 {\n    fn eq(&self, other: &Self) -> bool {\n        self.0.to_bits() == other.0.to_bits()\n    }\n}\n\nimpl Eq for Float64 {}\n\nimpl hash::Hash for Float64 {\n    fn hash<H: hash::Hasher>(&self, state: &mut H) {\n        self.0.to_bits().hash(state)\n    }\n}\n\nimpl fmt::Display for Float64 {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        self.0.fmt(f)\n    }\n}\n\nimpl From<f64> for Float64 {\n    fn from(f: f64) -> Self {\n        Self(f)\n    }\n}\n\nimpl From<Float64> for f64 {\n    fn from(f: Float64) -> Self {\n        f.0\n    }\n}\n\n#[allow(dead_code)]\npub fn deserialize_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let secs = f64::deserialize(deserializer)?;\n    let duration = Duration::from_secs_f64(secs);\n    Ok(duration)\n}\n\npub fn deserialize_millis<'de, D>(deserializer: D) -> Result<Duration, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    // Sometimes spotify will provide a negative number for a duration\n    let millis = u64::deserialize(deserializer).unwrap_or(0);\n    let duration = Duration::from_millis(millis);\n    Ok(duration)\n}\n\npub fn deserialize_date<'de, D>(deserializer: D) -> Result<Date, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let date = String::deserialize(deserializer)?;\n    let mut parts = date.splitn(3, '-');\n    let year = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);\n    let month: u8 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);\n    let month = Month::try_from(month).unwrap_or(Month::January);\n    let day = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);\n\n    Date::from_calendar_date(year, month, day)\n        .map_err(|_err| serde::de::Error::custom(\"Invalid date\"))\n}\n\npub fn deserialize_date_option<'de, D>(deserializer: D) -> Result<Option<Date>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    #[derive(Deserialize)]\n    struct Wrapper(#[serde(deserialize_with = \"deserialize_date\")] Date);\n\n    Ok(Option::deserialize(deserializer)?.map(|Wrapper(val)| val))\n}\n\npub fn deserialize_first_page<'de, D, T>(deserializer: D) -> Result<Vector<T>, D::Error>\nwhere\n    T: Clone,\n    T: Deserialize<'de>,\n    D: Deserializer<'de>,\n{\n    let page = Page::<T>::deserialize(deserializer)?;\n    Ok(page.items)\n}\n\npub fn deserialize_null_arc_str<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let opt = Option::deserialize(deserializer)?;\n    Ok(opt.unwrap_or_else(default_str))\n}\n\npub fn sanitize_html_string(text: &str) -> Arc<str> {\n    let sanitized = sanitize_str(&DEFAULT, text).unwrap_or_default();\n    Arc::from(sanitized.replace(\"&amp;\", \"&\"))\n}\n"
  },
  {
    "path": "psst-gui/src/delegate.rs",
    "content": "use directories::UserDirs;\nuse druid::{\n    commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target,\n    WindowDesc, WindowId,\n};\nuse std::fs;\nuse threadpool::ThreadPool;\n\nuse crate::ui::playlist::{\n    RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM,\n};\nuse crate::ui::theme;\nuse crate::ui::DOWNLOAD_ARTWORK;\nuse crate::{\n    cmd,\n    data::{AppState, Config},\n    ui,\n    webapi::WebApi,\n    widget::remote_image,\n};\n\npub struct Delegate {\n    main_window: Option<WindowId>,\n    preferences_window: Option<WindowId>,\n    credits_window: Option<WindowId>,\n    artwork_window: Option<WindowId>,\n    image_pool: ThreadPool,\n    size_updated: bool,\n}\n\nimpl Delegate {\n    pub fn new() -> Self {\n        const MAX_IMAGE_THREADS: usize = 32;\n\n        Self {\n            main_window: None,\n            preferences_window: None,\n            credits_window: None,\n            artwork_window: None,\n            image_pool: ThreadPool::with_name(\"image_loading\".into(), MAX_IMAGE_THREADS),\n            size_updated: false,\n        }\n    }\n\n    pub fn with_main(main_window: WindowId) -> Self {\n        let mut this = Self::new();\n        this.main_window.replace(main_window);\n        this\n    }\n\n    pub fn with_preferences(preferences_window: WindowId) -> Self {\n        let mut this = Self::new();\n        this.preferences_window.replace(preferences_window);\n        this\n    }\n\n    fn show_or_create_window<F>(\n        window_id_option: &mut Option<WindowId>,\n        create_window_fn: F,\n        ctx: &mut DelegateCtx,\n    ) where\n        F: FnOnce() -> WindowDesc<AppState>,\n    {\n        if let Some(id) = window_id_option {\n            ctx.submit_command(commands::SHOW_WINDOW.to(*id));\n        } else {\n            let window = create_window_fn();\n            *window_id_option = Some(window.id);\n            ctx.new_window(window);\n        }\n    }\n\n    fn show_main(&mut self, config: &Config, ctx: &mut DelegateCtx) {\n        let config_clone = config.clone();\n        Self::show_or_create_window(\n            &mut self.main_window,\n            || ui::main_window(&config_clone),\n            ctx,\n        );\n    }\n\n    fn show_account_setup(&mut self, ctx: &mut DelegateCtx) {\n        Self::show_or_create_window(&mut self.preferences_window, ui::account_setup_window, ctx);\n    }\n\n    fn show_preferences(&mut self, ctx: &mut DelegateCtx) {\n        Self::show_or_create_window(&mut self.preferences_window, ui::preferences_window, ctx);\n    }\n\n    fn close_all_windows(&mut self, ctx: &mut DelegateCtx) {\n        ctx.submit_command(commands::CLOSE_ALL_WINDOWS);\n        self.main_window = None;\n        self.preferences_window = None;\n        self.credits_window = None;\n    }\n\n    fn close_preferences(&mut self, ctx: &mut DelegateCtx) {\n        if let Some(id) = self.preferences_window.take() {\n            ctx.submit_command(commands::CLOSE_WINDOW.to(id));\n        }\n    }\n\n    fn close_credits(&mut self, ctx: &mut DelegateCtx) {\n        if let Some(id) = self.credits_window.take() {\n            ctx.submit_command(commands::CLOSE_WINDOW.to(id));\n        }\n    }\n\n    fn show_credits(&mut self, ctx: &mut DelegateCtx) -> WindowId {\n        match self.credits_window {\n            Some(id) => {\n                ctx.submit_command(commands::SHOW_WINDOW.to(id));\n                id\n            }\n            None => {\n                let window = WindowDesc::new(ui::credits::credits_widget())\n                    .title(\"Track Credits\")\n                    .window_size((theme::grid(50.0), theme::grid(55.0)))\n                    .resizable(false);\n                let window_id = window.id;\n                self.credits_window = Some(window_id);\n                ctx.new_window(window);\n                window_id\n            }\n        }\n    }\n\n    fn show_artwork(&mut self, ctx: &mut DelegateCtx) {\n        Self::show_or_create_window(&mut self.artwork_window, ui::artwork_window, ctx);\n    }\n}\n\nimpl AppDelegate<AppState> for Delegate {\n    fn command(\n        &mut self,\n        ctx: &mut DelegateCtx,\n        target: Target,\n        cmd: &Command,\n        data: &mut AppState,\n        _env: &Env,\n    ) -> Handled {\n        if cmd.is(cmd::SHOW_CREDITS_WINDOW) {\n            let _window_id = self.show_credits(ctx);\n            if let Some(track) = cmd.get(cmd::SHOW_CREDITS_WINDOW) {\n                ctx.submit_command(\n                    cmd::LOAD_TRACK_CREDITS\n                        .with(track.clone())\n                        .to(Target::Global),\n                );\n            }\n            Handled::Yes\n        } else if cmd.is(cmd::SHOW_MAIN) {\n            self.show_main(&data.config, ctx);\n            Handled::Yes\n        } else if cmd.is(cmd::SHOW_ACCOUNT_SETUP) {\n            self.show_account_setup(ctx);\n            Handled::Yes\n        } else if cmd.is(commands::SHOW_PREFERENCES) {\n            self.show_preferences(ctx);\n            Handled::Yes\n        } else if cmd.is(cmd::CLOSE_ALL_WINDOWS) {\n            self.close_all_windows(ctx);\n            Handled::Yes\n        } else if cmd.is(commands::CLOSE_WINDOW) {\n            if let Some(window_id) = self.preferences_window {\n                if target == Target::Window(window_id) {\n                    self.close_preferences(ctx);\n                    return Handled::Yes;\n                }\n            } else if let Some(window_id) = self.credits_window {\n                if target == Target::Window(window_id) {\n                    self.close_credits(ctx);\n                    return Handled::Yes;\n                }\n            }\n            Handled::No\n        } else if let Some(text) = cmd.get(cmd::COPY) {\n            Application::global().clipboard().put_string(text);\n            Handled::Yes\n        } else if let Some(text) = cmd.get(cmd::GO_TO_URL) {\n            let _ = open::that(text);\n            Handled::Yes\n        } else if let Handled::Yes = self.command_image(ctx, target, cmd, data) {\n            Handled::Yes\n        } else if let Some(link) = cmd.get(UNFOLLOW_PLAYLIST_CONFIRM) {\n            ctx.submit_command(UNFOLLOW_PLAYLIST.with(link.clone()));\n            Handled::Yes\n        } else if let Some(link) = cmd.get(RENAME_PLAYLIST_CONFIRM) {\n            ctx.submit_command(RENAME_PLAYLIST.with(link.clone()));\n            Handled::Yes\n        } else if cmd.is(cmd::QUIT_APP_WITH_SAVE) {\n            ctx.submit_command(commands::QUIT_APP);\n            Handled::Yes\n        } else if cmd.is(commands::QUIT_APP) {\n            Handled::No\n        } else if cmd.is(crate::cmd::SHOW_ARTWORK) {\n            self.show_artwork(ctx);\n            Handled::Yes\n        } else if let Some((url, title)) = cmd.get(DOWNLOAD_ARTWORK) {\n            let safe_title = title.replace(['/', '\\\\', ':', '*', '?', '\"', '<', '>', '|'], \"_\");\n            let file_name = format!(\"{safe_title} cover.jpg\");\n\n            if let Some(user_dirs) = UserDirs::new() {\n                if let Some(download_dir) = user_dirs.download_dir() {\n                    let path = download_dir.join(file_name);\n\n                    match ureq::get(url)\n                        .call()\n                        .and_then(|response| -> Result<(), ureq::Error> {\n                            let mut file = fs::File::create(&path)?;\n                            let mut reader = response.into_body().into_reader();\n                            std::io::copy(&mut reader, &mut file)?;\n                            Ok(())\n                        }) {\n                        Ok(_) => data.info_alert(\"Cover saved to Downloads folder.\"),\n                        Err(_) => data.error_alert(\"Failed to download and save artwork\"),\n                    }\n                }\n            }\n            Handled::Yes\n        } else {\n            Handled::No\n        }\n    }\n\n    fn window_removed(\n        &mut self,\n        id: WindowId,\n        data: &mut AppState,\n        _env: &Env,\n        ctx: &mut DelegateCtx,\n    ) {\n        if self.credits_window == Some(id) {\n            self.credits_window = None;\n            data.credits = None;\n        }\n        if self.preferences_window == Some(id) {\n            self.preferences_window.take();\n            data.preferences.reset();\n            data.preferences.auth.clear();\n        }\n        if self.main_window == Some(id) {\n            data.config.volume = data.playback.volume;\n            data.config.save();\n            ctx.submit_command(commands::CLOSE_ALL_WINDOWS);\n            ctx.submit_command(commands::QUIT_APP);\n        }\n        if self.artwork_window == Some(id) {\n            self.artwork_window = None;\n        }\n    }\n\n    fn event(\n        &mut self,\n        ctx: &mut DelegateCtx,\n        window_id: WindowId,\n        event: Event,\n        data: &mut AppState,\n        _env: &Env,\n    ) -> Option<Event> {\n        if self.main_window == Some(window_id) {\n            if let Event::WindowSize(size) = event {\n                if !self.size_updated {\n                    self.size_updated = true;\n                } else {\n                    data.config.window_size = size;\n                }\n            }\n        } else if [\n            self.preferences_window,\n            self.artwork_window,\n            self.credits_window,\n        ]\n        .contains(&Some(window_id))\n        {\n            if let Event::KeyDown(key_event) = &event {\n                if key_event.key == druid::KbKey::Escape {\n                    ctx.submit_command(commands::CLOSE_WINDOW.to(window_id));\n                    return None;\n                }\n            }\n        }\n        Some(event)\n    }\n}\n\nimpl Delegate {\n    fn command_image(\n        &mut self,\n        ctx: &mut DelegateCtx,\n        target: Target,\n        cmd: &Command,\n        _data: &mut AppState,\n    ) -> Handled {\n        if let Some(location) = cmd.get(remote_image::REQUEST_DATA).cloned() {\n            let sink = ctx.get_external_handle();\n            if let Some(image_buf) = WebApi::global().get_cached_image(&location) {\n                let payload = remote_image::ImagePayload {\n                    location,\n                    image_buf,\n                };\n                sink.submit_command(remote_image::PROVIDE_DATA, payload, target)\n                    .unwrap();\n            } else {\n                self.image_pool.execute(move || {\n                    let result = WebApi::global().get_image(location.clone());\n                    match result {\n                        Ok(image_buf) => {\n                            let payload = remote_image::ImagePayload {\n                                location,\n                                image_buf,\n                            };\n                            sink.submit_command(remote_image::PROVIDE_DATA, payload, target)\n                                .unwrap();\n                        }\n                        Err(err) => {\n                            log::warn!(\"failed to fetch image: {err}\")\n                        }\n                    }\n                });\n            }\n            Handled::Yes\n        } else {\n            Handled::No\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/error.rs",
    "content": "use std::{error, fmt};\n\nuse druid::Data;\n\n#[derive(Clone, Debug, Data)]\npub enum Error {\n    WebApiError(String),\n}\n\nimpl error::Error for Error {}\n\nimpl fmt::Display for Error {\n    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n        match self {\n            Self::WebApiError(err) => f.write_str(err),\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/main.rs",
    "content": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n#![allow(clippy::new_without_default, clippy::type_complexity)]\n\nmod cmd;\nmod controller;\nmod data;\nmod delegate;\nmod error;\nmod ui;\nmod webapi;\nmod widget;\n\nuse druid::AppLauncher;\nuse env_logger::{Builder, Env};\nuse webapi::WebApi;\n\nuse psst_core::cache::Cache;\n\nuse crate::{\n    data::{AppState, Config},\n    delegate::Delegate,\n};\n\nconst ENV_LOG: &str = \"PSST_LOG\";\nconst ENV_LOG_STYLE: &str = \"PSST_LOG_STYLE\";\n\nfn main() {\n    // Setup logging from the env variables, with defaults.\n    Builder::from_env(\n        Env::new()\n            .filter_or(ENV_LOG, \"info\")\n            .write_style(ENV_LOG_STYLE),\n    )\n    .init();\n\n    // Load configuration\n    let config = Config::load().unwrap_or_default();\n\n    let paginated_limit = config.paginated_limit;\n    let mut state = AppState::default_with_config(config.clone());\n\n    if let Some(cache_dir) = Config::cache_dir() {\n        match Cache::new(cache_dir) {\n            Ok(cache) => {\n                state.preferences.cache = Some(cache);\n            }\n            Err(err) => {\n                log::error!(\"Failed to create cache: {err}\");\n            }\n        }\n    }\n\n    WebApi::new(\n        state.session.clone(),\n        Config::proxy().as_deref(),\n        Config::cache_dir(),\n        paginated_limit,\n    )\n    .install_as_global();\n\n    let delegate;\n    let launcher;\n    if state.config.has_credentials() {\n        // Credentials are configured, open the main window.\n        let window = ui::main_window(&state.config);\n        delegate = Delegate::with_main(window.id);\n        launcher = AppLauncher::with_window(window).configure_env(ui::theme::setup);\n\n        // Load user's local tracks for the WebApi.\n        WebApi::global().load_local_tracks(state.config.username().unwrap());\n    } else {\n        // No configured credentials, open the account setup.\n        let window = ui::account_setup_window();\n        delegate = Delegate::with_preferences(window.id);\n        launcher = AppLauncher::with_window(window).configure_env(ui::theme::setup);\n    };\n\n    launcher\n        .delegate(delegate)\n        .launch(state)\n        .expect(\"Application launch\");\n}\n"
  },
  {
    "path": "psst-gui/src/ui/album.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{CrossAxisAlignment, Flex, Label, LineBreaking, List, ViewSwitcher},\n    LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{\n        Album, AlbumDetail, AlbumLink, AppState, ArtistLink, Cached, Ctx, Library, Nav, Playable,\n        PlaybackOrigin, WithCtx,\n    },\n    ui::playable::PlayableIter,\n    webapi::WebApi,\n    widget::{icons, Async, MyWidgetExt, RemoteImage},\n};\n\nuse super::{artist, library, playable, theme, track, utils};\n\npub const LOAD_DETAIL: Selector<AlbumLink> = Selector::new(\"app.album.load-detail\");\n\npub fn detail_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        loaded_detail_widget,\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::album_detail.then(AlbumDetail::album),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_DETAIL,\n        |d| WebApi::global().get_album(&d.id),\n        |_, data, d| data.album_detail.album.defer(d),\n        |_, data, r| data.album_detail.album.update(r),\n    )\n}\n\nfn loaded_detail_widget() -> impl Widget<WithCtx<Cached<Arc<Album>>>> {\n    let album_cover = rounded_cover_widget(theme::grid(10.0))\n        .lens(Ctx::data())\n        .context_menu(album_ctx_menu);\n\n    let album_artists = List::new(artist::link_widget).lens(Album::artists.in_arc());\n\n    let album_date = Label::dynamic(|album: &Arc<Album>, _| album.release())\n        .with_text_size(theme::TEXT_SIZE_SMALL);\n\n    let album_label = Label::raw()\n        .with_line_break_mode(LineBreaking::Clip)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .lens(Album::label.in_arc());\n\n    let album_info = Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(album_artists)\n        .with_default_spacer()\n        .with_child(album_date)\n        .with_default_spacer()\n        .with_child(album_label)\n        .padding(theme::grid(1.0));\n\n    let album_top = Flex::row()\n        .with_spacer(theme::grid(4.2))\n        .with_child(album_cover)\n        .with_default_spacer()\n        .with_flex_child(album_info.lens(Ctx::data()), 1.0);\n\n    let album_tracks = playable::list_widget(playable::Display {\n        track: track::Display {\n            number: true,\n            title: true,\n            artist: true,\n            ..track::Display::empty()\n        },\n    });\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_spacer(theme::grid(1.0))\n        .with_child(album_top)\n        .with_spacer(theme::grid(1.0))\n        .with_child(album_tracks)\n        .lens(Ctx::map(Cached::data))\n}\n\nfn cover_widget(size: f64) -> impl Widget<Arc<Album>> {\n    RemoteImage::new(utils::placeholder_widget(), move |album: &Arc<Album>, _| {\n        album.image(size, size).map(|image| image.url.clone())\n    })\n    .fix_size(size, size)\n}\n\nfn rounded_cover_widget(size: f64) -> impl Widget<Arc<Album>> {\n    cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0))\n}\n\npub fn album_widget(horizontal: bool) -> impl Widget<WithCtx<Arc<Album>>> {\n    let (album_cover_size, album_name_layout) = if horizontal {\n        (16.0, Flex::column())\n    } else {\n        (6.0, Flex::row())\n    };\n    let album_cover = rounded_cover_widget(theme::grid(album_cover_size));\n\n    let album_name = album_name_layout\n        .with_child(\n            Label::raw()\n                .with_font(theme::UI_FONT_MEDIUM)\n                .with_line_break_mode(LineBreaking::Clip)\n                .lens(Album::name.in_arc()),\n        )\n        .with_spacer(theme::grid(0.5))\n        .with_child(ViewSwitcher::new(\n            |album: &Arc<Album>, _| album.has_explicit(),\n            |selector: &bool, _, _| match selector {\n                true => icons::EXPLICIT.scale(theme::ICON_SIZE_TINY).boxed(),\n                false => Box::new(Flex::column()),\n            },\n        ));\n\n    let album_artists = List::new(|| {\n        Label::raw()\n            .with_text_size(theme::TEXT_SIZE_SMALL)\n            .with_line_break_mode(LineBreaking::WordWrap)\n            .lens(ArtistLink::name)\n    })\n    .horizontal()\n    .with_spacing(theme::grid(1.0))\n    .lens(Album::artists.in_arc());\n\n    let album_date = Label::<Arc<Album>>::dynamic(|album, _| album.release_year())\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR);\n\n    let album_layout = if horizontal {\n        Flex::column()\n            .with_child(album_cover)\n            .with_default_spacer()\n            .with_child(\n                Flex::column()\n                    .cross_axis_alignment(CrossAxisAlignment::Start)\n                    .with_child(album_name)\n                    .with_spacer(1.0)\n                    .with_child(album_artists)\n                    .with_spacer(1.0)\n                    .with_child(album_date)\n                    .align_horizontal(UnitPoint::CENTER)\n                    .align_vertical(UnitPoint::TOP)\n                    .fix_size(theme::grid(16.0), theme::grid(8.0)),\n            )\n            .align_left()\n    } else {\n        Flex::row()\n            .with_child(album_cover)\n            .with_default_spacer()\n            .with_flex_child(\n                Flex::column()\n                    .cross_axis_alignment(CrossAxisAlignment::Start)\n                    .with_child(album_name)\n                    .with_spacer(1.0)\n                    .with_child(album_artists)\n                    .with_spacer(1.0)\n                    .with_child(album_date),\n                1.0,\n            )\n            .align_left()\n    };\n\n    album_layout\n        .padding(theme::grid(1.0))\n        .lens(Ctx::data())\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, album, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::AlbumDetail(album.data.link(), None)));\n        })\n        .context_menu(album_ctx_menu)\n}\n\nfn album_ctx_menu(album: &WithCtx<Arc<Album>>) -> Menu<AppState> {\n    album_menu(&album.data, &album.ctx.library)\n}\n\nfn album_menu(album: &Arc<Album>, library: &Arc<Library>) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    for artist_link in &album.artists {\n        let more_than_one_artist = album.artists.len() > 1;\n        let title = if more_than_one_artist {\n            LocalizedString::new(\"menu-item-show-artist-name\")\n                .with_placeholder(format!(\"Go to Artist \\\"{}\\\"\", artist_link.name))\n        } else {\n            LocalizedString::new(\"menu-item-show-artist\").with_placeholder(\"Go to Artist\")\n        };\n        menu = menu.entry(\n            MenuItem::new(title)\n                .command(cmd::NAVIGATE.with(Nav::ArtistDetail(artist_link.to_owned()))),\n        );\n    }\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Album\"),\n        )\n        .command(cmd::COPY.with(album.url())),\n    );\n\n    menu = menu.separator();\n\n    if library.contains_album(album) {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-remove-from-library\")\n                    .with_placeholder(\"Remove Album from Library\"),\n            )\n            .command(library::UNSAVE_ALBUM.with(album.link())),\n        );\n    } else {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-save-to-library\")\n                    .with_placeholder(\"Save Album to Library\"),\n            )\n            .command(library::SAVE_ALBUM.with(album.clone())),\n        );\n    }\n\n    menu\n}\n\nimpl PlayableIter for Arc<Album> {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Album(self.link())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.clone().into_tracks_with_context().iter().enumerate() {\n            cb(Playable::Track(track.clone()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/ui/artist.rs",
    "content": "use druid::{\n    im::Vector,\n    kurbo::Circle,\n    widget::{CrossAxisAlignment, Either, Flex, Label, LabelText, LineBreaking, List, Scroll},\n    Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget,\n    WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{\n        AppState, Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistTracks, Cached,\n        Ctx, Nav, WithCtx,\n    },\n    ui::utils::{stat_row, InfoLayout},\n    webapi::WebApi,\n    widget::{Async, Empty, MyWidgetExt, RemoteImage},\n};\n\nuse super::{\n    album, playable, theme, track,\n    utils::{self},\n};\n\npub const LOAD_DETAIL: Selector<ArtistLink> = Selector::new(\"app.artist.load-detail\");\n\npub fn detail_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .with_child(async_artist_info().padding((theme::grid(1.0), 0.0)))\n        .with_child(async_top_tracks_widget())\n        .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0)))\n        .with_child(async_related_widget().padding((theme::grid(1.0), 0.0)))\n}\n\nfn async_top_tracks_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        top_tracks_widget,\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::artist_detail.then(ArtistDetail::top_tracks),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_DETAIL,\n        |d| WebApi::global().get_artist_top_tracks(&d.id),\n        |_, data, d| data.artist_detail.top_tracks.defer(d),\n        |_, data, (d, r)| {\n            let r = r.map(|tracks| ArtistTracks {\n                id: d.id.clone(),\n                name: d.name.clone(),\n                tracks,\n            });\n            data.artist_detail.top_tracks.update((d, r))\n        },\n    )\n}\n\nfn async_albums_widget() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, albums_widget, utils::error_widget)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::artist_detail.then(ArtistDetail::albums),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_DETAIL,\n            |d| WebApi::global().get_artist_albums(&d.id),\n            |_, data, d| data.artist_detail.albums.defer(d),\n            |_, data, r| data.artist_detail.albums.update(r),\n        )\n}\n\nfn async_artist_info() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, artist_info_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::artist_detail.then(ArtistDetail::artist_info),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_DETAIL,\n            |d| WebApi::global().get_artist_info(&d.id),\n            |_, data, d| data.artist_detail.artist_info.defer(d),\n            |_, data, r| data.artist_detail.artist_info.update(r),\n        )\n}\n\nfn async_related_widget() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, related_widget, utils::error_widget)\n        .lens(AppState::artist_detail.then(ArtistDetail::related_artists))\n        .on_command_async(\n            LOAD_DETAIL,\n            |d| WebApi::global().get_related_artists(&d.id),\n            |_, data, d| data.artist_detail.related_artists.defer(d),\n            |_, data, r| data.artist_detail.related_artists.update(r),\n        )\n}\n\npub fn artist_widget(horizontal: bool) -> impl Widget<Artist> {\n    let (mut artist, artist_image) = if horizontal {\n        (Flex::column(), cover_widget(theme::grid(16.0)))\n    } else {\n        (Flex::row(), cover_widget(theme::grid(6.0)))\n    };\n\n    artist = if horizontal {\n        artist\n            .with_child(artist_image)\n            .with_default_spacer()\n            .with_child(\n                Label::raw()\n                    .with_font(theme::UI_FONT_MEDIUM)\n                    .align_horizontal(UnitPoint::CENTER)\n                    .align_vertical(UnitPoint::TOP)\n                    .fix_size(theme::grid(16.0), theme::grid(8.0))\n                    .lens(Artist::name),\n            )\n    } else {\n        artist\n            .with_child(artist_image)\n            .with_default_spacer()\n            .with_flex_child(\n                Label::raw()\n                    .with_font(theme::UI_FONT_MEDIUM)\n                    .lens(Artist::name),\n                1.0,\n            )\n    };\n\n    artist\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, artist, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::ArtistDetail(artist.link())));\n        })\n        .context_menu(|artist| artist_menu(&artist.link()))\n}\n\npub fn link_widget() -> impl Widget<ArtistLink> {\n    Label::raw()\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .with_font(theme::UI_FONT_MEDIUM)\n        .link()\n        .lens(ArtistLink::name)\n        .on_left_click(|ctx, _, link, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::ArtistDetail(link.to_owned())));\n        })\n        .context_menu(artist_menu)\n}\n\npub fn cover_widget(size: f64) -> impl Widget<Artist> {\n    let radius = size / 2.0;\n    RemoteImage::new(utils::placeholder_widget(), move |artist: &Artist, _| {\n        artist.image(size, size).map(|image| image.url.clone())\n    })\n    .fix_size(size, size)\n    .clip(Circle::new((radius, radius), radius))\n}\n\nfn artist_info_widget() -> impl Widget<WithCtx<ArtistInfo>> {\n    let size = theme::grid(16.0);\n\n    let artist_image = RemoteImage::new(\n        utils::placeholder_widget(),\n        move |artist: &ArtistInfo, _| Some(artist.main_image.clone()),\n    )\n    .fix_size(size, size)\n    .clip(Size::new(size, size).to_rounded_rect(4.0))\n    .lens(Ctx::data());\n\n    let biography = Scroll::new(\n        Label::new(|data: &ArtistInfo, _env: &_| data.bio.clone())\n            .with_line_break_mode(LineBreaking::WordWrap)\n            .with_text_size(theme::TEXT_SIZE_NORMAL)\n            .lens(Ctx::data()),\n    )\n    .vertical();\n\n    let artist_stats = Flex::column()\n        .with_child(stat_row(\"Followers:\", |info: &ArtistInfo| {\n            utils::format_number_with_commas(info.stats.followers)\n        }))\n        .with_default_spacer()\n        .with_child(stat_row(\"Monthly Listeners:\", |info: &ArtistInfo| {\n            utils::format_number_with_commas(info.stats.monthly_listeners)\n        }))\n        .with_default_spacer()\n        .with_child(Either::new(\n            |ctx: &WithCtx<ArtistInfo>, _| ctx.data.stats.world_rank > 0,\n            stat_row(\"Ranking:\", |info: &ArtistInfo| {\n                format!(\n                    \"#{} in the world\",\n                    utils::format_number_with_commas(info.stats.world_rank)\n                )\n            }),\n            Empty,\n        ));\n\n    Flex::row()\n        .with_child(artist_image)\n        .with_spacer(theme::grid(1.0))\n        .with_flex_child(\n            Flex::row().with_flex_child(InfoLayout::new(biography, artist_stats), 1.0),\n            1.0,\n        )\n        .context_menu(|artist| artist_info_menu(&artist.data))\n        .padding((0.0, theme::grid(1.0))) // Keep overall vertical padding\n}\n\nfn top_tracks_widget() -> impl Widget<WithCtx<ArtistTracks>> {\n    playable::list_widget(playable::Display {\n        track: track::Display {\n            title: true,\n            album: true,\n            popularity: true,\n            cover: true,\n            ..track::Display::empty()\n        },\n    })\n}\n\nfn albums_widget() -> impl Widget<WithCtx<ArtistAlbums>> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(header_widget(\"Albums\"))\n        .with_child(List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::albums)))\n        .with_child(header_widget(\"Singles\"))\n        .with_child(List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::singles)))\n        .with_child(header_widget(\"Compilations\"))\n        .with_child(\n            List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::compilations)),\n        )\n        .with_child(header_widget(\"Appears On\"))\n        .with_child(\n            List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::appears_on)),\n        )\n}\n\nfn related_widget() -> impl Widget<Cached<Vector<Artist>>> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(header_widget(\"Related Artists\"))\n        .with_child(List::new(|| artist_widget(false)))\n        .lens(Cached::data)\n}\n\nfn header_widget<T: Data>(text: impl Into<LabelText<T>>) -> impl Widget<T> {\n    Label::new(text)\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .padding(Insets::new(0.0, theme::grid(2.0), 0.0, theme::grid(1.0)))\n}\n\nfn artist_info_menu(artist: &ArtistInfo) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    for artist_link in &artist.artist_links {\n        let platform = if artist_link.contains(\"wikipedia.org\") {\n            \"Wikipedia\"\n        } else {\n            artist_link\n                .strip_prefix(\"https://\")\n                .unwrap_or(artist_link)\n                .split('.')\n                .next()\n                .unwrap_or(\"Unknown\")\n        };\n\n        let title = LocalizedString::new(\"menu-item-go-to-social\").with_placeholder(format!(\n            \"Go to their {}\",\n            platform\n                .chars()\n                .next()\n                .unwrap()\n                .to_uppercase()\n                .collect::<String>()\n                + &platform[1..]\n        ));\n\n        menu =\n            menu.entry(MenuItem::new(title).command(cmd::GO_TO_URL.with(artist_link.to_owned())));\n    }\n\n    menu\n}\n\nfn artist_menu(artist: &ArtistLink) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Artist\"),\n        )\n        .command(cmd::COPY.with(artist.url())),\n    );\n\n    menu\n}\n"
  },
  {
    "path": "psst-gui/src/ui/credits.rs",
    "content": "use std::sync::Arc;\n\nuse crate::widget::Empty;\nuse crate::{\n    cmd,\n    data::{AppState, ArtistLink, Nav},\n    ui::theme,\n    ui::utils,\n};\nuse druid::{\n    widget::{Controller, CrossAxisAlignment, Either, Flex, Label, List, Maybe, Painter, Scroll},\n    Cursor, Data, Env, Event, EventCtx, Lens, RenderContext, Target, UpdateCtx, Widget, WidgetExt,\n};\nuse serde::Deserialize;\n\n#[derive(Debug, Clone, Data, Lens, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct TrackCredits {\n    pub track_uri: String,\n    pub track_title: String,\n    #[serde(skip_serializing_if = \"Vec::is_empty\")]\n    pub role_credits: Arc<Vec<RoleCredit>>,\n    #[serde(skip_serializing_if = \"Vec::is_empty\")]\n    pub extended_credits: Arc<Vec<String>>,\n    #[serde(skip_serializing_if = \"Vec::is_empty\")]\n    pub source_names: Arc<Vec<String>>,\n}\n\n#[derive(Debug, Clone, Data, Lens, Deserialize)]\npub struct RoleCredit {\n    #[serde(rename = \"roleTitle\")]\n    pub role_title: String,\n    pub artists: Arc<Vec<CreditArtist>>,\n}\n\n#[derive(Debug, Clone, Data, Lens, Deserialize)]\npub struct CreditArtist {\n    pub uri: Option<String>,\n    pub name: String,\n    #[serde(rename = \"imageUri\")]\n    pub image_uri: Option<String>,\n    #[serde(rename = \"externalUrl\")]\n    pub external_url: Option<String>,\n    #[serde(rename = \"creatorUri\")]\n    pub creator_uri: Option<String>,\n    #[serde(default)]\n    pub subroles: Arc<Vec<String>>,\n    #[serde(default)]\n    pub weight: f64,\n}\n\npub fn credits_widget() -> impl Widget<AppState> {\n    Scroll::new(\n        Maybe::new(\n            || {\n                Flex::column()\n                    .cross_axis_alignment(CrossAxisAlignment::Start)\n                    .with_child(\n                        Label::new(|data: &TrackCredits, _: &_| data.track_title.clone())\n                            .with_font(theme::UI_FONT_MEDIUM)\n                            .with_text_size(theme::TEXT_SIZE_LARGE)\n                            .padding(theme::grid(2.0))\n                            .expand_width(),\n                    )\n                    .with_child(Either::new(\n                        |data: &TrackCredits, _| data.role_credits.is_empty(),\n                        Empty,\n                        List::new(role_credit_widget).lens(TrackCredits::role_credits),\n                    ))\n                    .with_child(Either::new(\n                        |data: &TrackCredits, _| data.source_names.is_empty(),\n                        Empty,\n                        Label::new(|data: &TrackCredits, _: &_| {\n                            format!(\"Source: {}\", data.source_names.join(\", \"))\n                        })\n                        .with_text_size(theme::TEXT_SIZE_SMALL)\n                        .with_text_color(theme::PLACEHOLDER_COLOR)\n                        .padding(theme::grid(2.0)),\n                    ))\n                    .padding(theme::grid(2.0))\n            },\n            utils::spinner_widget,\n        )\n        .lens(AppState::credits)\n        .controller(CreditsController),\n    )\n    .vertical()\n    .expand()\n}\n\nfn role_credit_widget() -> impl Widget<RoleCredit> {\n    Either::new(\n        |role: &RoleCredit, _| role.artists.is_empty(),\n        Empty,\n        Flex::column()\n            .cross_axis_alignment(CrossAxisAlignment::Start)\n            .with_child(\n                Label::new(|role: &RoleCredit, _: &_| capitalize_first(&role.role_title))\n                    .with_text_size(theme::TEXT_SIZE_NORMAL)\n                    .padding((theme::grid(2.0), theme::grid(1.0))),\n            )\n            .with_child(\n                List::new(credit_artist_widget)\n                    .lens(RoleCredit::artists)\n                    .padding((theme::grid(2.0), 0.0, theme::grid(2.0), 0.0)),\n            ),\n    )\n}\n\nfn credit_artist_widget() -> impl Widget<CreditArtist> {\n    let painter = Painter::new(|ctx, data: &CreditArtist, env| {\n        let bounds = ctx.size().to_rect();\n\n        if ctx.is_hot() && data.uri.is_some() {\n            ctx.fill(bounds, &env.get(theme::LINK_HOT_COLOR));\n        } else if data.uri.is_some() {\n            ctx.fill(bounds, &env.get(theme::LINK_COLD_COLOR));\n        }\n\n        if ctx.is_active() && data.uri.is_some() {\n            ctx.fill(bounds, &env.get(theme::LINK_ACTIVE_COLOR));\n        }\n    });\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(\n                    Label::new(|artist: &CreditArtist, _: &_| proper_case(&artist.name))\n                        .with_font(theme::UI_FONT_MEDIUM),\n                )\n                .with_child(\n                    Label::new(|artist: &CreditArtist, _: &_| {\n                        capitalize_first(&artist.subroles.join(\", \"))\n                    })\n                    .with_text_size(theme::TEXT_SIZE_SMALL)\n                    .with_text_color(theme::PLACEHOLDER_COLOR),\n                )\n                .padding(theme::grid(1.0))\n                .expand_width()\n                .background(painter)\n                .rounded(theme::BUTTON_BORDER_RADIUS)\n                .on_click(|ctx: &mut EventCtx, data: &mut CreditArtist, _: &Env| {\n                    if let Some(uri) = &data.uri {\n                        let artist_id = uri.split(':').next_back().unwrap_or(\"\").to_string();\n                        let artist_link = ArtistLink {\n                            id: artist_id.into(),\n                            name: data.name.clone().into(),\n                        };\n                        ctx.submit_command(\n                            cmd::NAVIGATE\n                                .with(Nav::ArtistDetail(artist_link))\n                                .to(Target::Global),\n                        );\n                    }\n                })\n                .disabled_if(|artist: &CreditArtist, _| artist.uri.is_none())\n                .controller(CursorController),\n        )\n        .padding((0.0, theme::grid(0.5)))\n}\n\nstruct CursorController;\n\nimpl<W: Widget<CreditArtist>> Controller<CreditArtist, W> for CursorController {\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut CreditArtist,\n        env: &Env,\n    ) {\n        if let Event::MouseMove(_) = event {\n            if data.uri.is_some() {\n                ctx.set_cursor(&Cursor::Pointer);\n            } else {\n                ctx.clear_cursor();\n            }\n        }\n        child.event(ctx, event, data, env)\n    }\n}\n\nfn proper_case(s: &str) -> String {\n    s.split_whitespace()\n        .map(|word| {\n            let mut chars = word.chars();\n            match chars.next() {\n                None => String::new(),\n                Some(f) => {\n                    f.to_uppercase().collect::<String>() + chars.as_str().to_lowercase().as_str()\n                }\n            }\n        })\n        .collect::<Vec<String>>()\n        .join(\" \")\n}\n\nfn capitalize_first(s: &str) -> String {\n    let mut chars = s.chars();\n    match chars.next() {\n        None => String::new(),\n        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),\n    }\n}\n\n/// Controller that handles updating the credits view when data changes\npub struct CreditsController;\n\nimpl<W: Widget<AppState>> Controller<AppState, W> for CreditsController {\n    fn update(\n        &mut self,\n        child: &mut W,\n        ctx: &mut UpdateCtx,\n        old_data: &AppState,\n        data: &AppState,\n        env: &Env,\n    ) {\n        if !old_data.credits.same(&data.credits) {\n            ctx.request_layout();\n            ctx.request_paint();\n        }\n        child.update(ctx, old_data, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/ui/episode.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{CrossAxisAlignment, Flex, Label, LineBreaking},\n    LensExt, LocalizedString, Menu, MenuItem, Size, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{AppState, Episode, Library, Nav},\n    widget::{fill_between::FillBetween, FadeOut, MyWidgetExt, RemoteImage},\n};\n\nuse super::{\n    playable::{self, PlayRow},\n    theme, utils,\n};\n\npub fn playable_widget() -> impl Widget<PlayRow<Arc<Episode>>> {\n    let cover = rounded_cover_widget(theme::grid(4.0)).lens(PlayRow::item);\n\n    let name = Label::raw()\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .lens(PlayRow::item.then(Episode::name.in_arc()));\n\n    let description = Label::raw()\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .lens(PlayRow::item.then(Episode::description.in_arc()));\n    let description =\n        FadeOut::bottom(description, theme::grid(3.5)).with_color(theme::BACKGROUND_LIGHT);\n\n    let release = Label::<Arc<Episode>>::dynamic(|episode, _| episode.release())\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::GREY_300)\n        .lens(PlayRow::item)\n        .padding_right(theme::grid(1.0));\n\n    let is_playing = playable::is_playing_marker_widget().lens(PlayRow::is_playing);\n\n    let duration = Label::<Arc<Episode>>::dynamic(|episode, _| utils::as_human(episode.duration))\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .lens(PlayRow::item);\n\n    let top_row = Flex::row()\n        .cross_axis_alignment(CrossAxisAlignment::Center)\n        .with_flex_child(FillBetween::new(release, is_playing), 1.0)\n        .with_default_spacer()\n        .with_child(duration);\n\n    let content = Flex::row()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(cover)\n        .with_default_spacer()\n        .with_flex_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(name)\n                .with_default_spacer()\n                .with_child(description),\n            1.0,\n        );\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(top_row)\n        .with_default_spacer()\n        .with_child(content)\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, row, _| ctx.submit_notification(cmd::PLAY.with(row.position)))\n        .context_menu(episode_row_menu)\n}\n\nfn cover_widget(size: f64) -> impl Widget<Arc<Episode>> {\n    RemoteImage::new(\n        utils::placeholder_widget(),\n        move |episode: &Arc<Episode>, _| episode.image(size, size).map(|image| image.url.clone()),\n    )\n    .fix_size(size, size)\n}\n\nfn rounded_cover_widget(size: f64) -> impl Widget<Arc<Episode>> {\n    // TODO: Take the radius from theme.\n    cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0))\n}\n\nfn episode_row_menu(row: &PlayRow<Arc<Episode>>) -> Menu<AppState> {\n    episode_menu(&row.item, &row.ctx.library)\n}\n\npub fn episode_menu(episode: &Episode, _library: &Arc<Library>) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    menu = menu.entry(\n        MenuItem::new(LocalizedString::new(\"menu-item-show-show\").with_placeholder(\"Go to Show\"))\n            .command(cmd::NAVIGATE.with(Nav::ShowDetail(episode.show.clone()))),\n    );\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Episode\"),\n        )\n        .command(cmd::COPY.with(episode.url())),\n    );\n\n    menu\n}\n"
  },
  {
    "path": "psst-gui/src/ui/find.rs",
    "content": "use druid::{\n    widget::{prelude::*, Controller, Either, Flex, Label, TextBox},\n    KbKey, Selector, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    controller::InputController,\n    data::{FindQuery, Finder, MatchFindQuery},\n    ui::theme,\n    widget::{Empty, MyWidgetExt},\n};\n\n#[derive(Clone)]\npub struct Find {\n    sender: WidgetId,\n    query: FindQuery,\n}\n\n#[derive(Clone)]\nstruct Report {\n    sender: WidgetId,\n}\n\nconst FIND: Selector = Selector::new(\"find\");\nconst REPORT_MATCH: Selector<Report> = Selector::new(\"report-match\");\nconst FOCUS_MATCH: Selector = Selector::new(\"focus-match\");\n\npub struct Findable<W> {\n    inner: W,\n    selector: Selector<Find>,\n    is_matching: bool,\n}\n\nimpl<W> Findable<W> {\n    pub fn new(inner: W, selector: Selector<Find>) -> Self {\n        Self {\n            inner,\n            selector,\n            is_matching: false,\n        }\n    }\n\n    fn set_state(&mut self, ctx: &mut EventCtx, matches: bool) {\n        if self.is_matching != matches {\n            self.is_matching = matches;\n            ctx.request_paint();\n        }\n    }\n}\n\nimpl<T, W> Widget<T> for Findable<W>\nwhere\n    W: Widget<T>,\n    T: MatchFindQuery,\n{\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        match event {\n            Event::Command(cmd) if cmd.is(self.selector) => {\n                let Find { sender, query } = cmd.get_unchecked(self.selector);\n                self.set_state(\n                    ctx,\n                    if query.is_empty() {\n                        false\n                    } else {\n                        data.matches_query(query)\n                    },\n                );\n                if self.is_matching {\n                    let report = Report {\n                        sender: ctx.widget_id(),\n                    };\n                    ctx.submit_command(REPORT_MATCH.with(report).to(*sender));\n                }\n            }\n            Event::Command(cmd) if cmd.is(FOCUS_MATCH) => {\n                ctx.scroll_to_view();\n            }\n            _ => {}\n        }\n        self.inner.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.inner.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        self.inner.update(ctx, old_data, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        self.inner.layout(ctx, bc, data, env)\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        if self.is_matching {\n            let bounds = ctx\n                .size()\n                .to_rect()\n                .inset(-2.0)\n                .to_rounded_rect(env.get(theme::BUTTON_BORDER_RADIUS));\n            ctx.fill(bounds, &env.get(theme::GREY_500));\n        }\n        self.inner.paint(ctx, data, env);\n    }\n}\n\npub fn finder_widget(selector: Selector<Find>, label: &'static str) -> impl Widget<Finder> {\n    let input_id = WidgetId::next();\n\n    let input = TextBox::new()\n        .with_placeholder(label)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .controller(InputController::new())\n        .with_id(input_id)\n        .expand_width()\n        .lens(Finder::query);\n\n    let not_found = Label::new(\"Not Found\")\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR);\n\n    let results = Label::dynamic(|finder: &Finder, _| {\n        format!(\"{} / {}\", finder.focused_result + 1, finder.results)\n    })\n    .with_text_size(theme::TEXT_SIZE_SMALL)\n    .with_text_color(theme::PLACEHOLDER_COLOR);\n\n    let previous = Label::new(\"‹\")\n        .padding(theme::grid(0.5))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|_, _, data: &mut Finder, _| data.focus_previous());\n\n    let next = Label::new(\"›\")\n        .padding(theme::grid(0.5))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|_, _, data: &mut Finder, _| data.focus_next());\n\n    let results_with_controls = Either::new(\n        |data, _| data.results > 0,\n        Flex::row()\n            .with_child(previous)\n            .with_spacer(theme::grid(0.5))\n            .with_child(results)\n            .with_spacer(theme::grid(0.5))\n            .with_child(next),\n        not_found,\n    );\n\n    let finder = Flex::row()\n        .with_flex_child(input, 1.0)\n        .with_default_spacer()\n        .with_child(results_with_controls)\n        .padding(theme::grid(1.0))\n        .background(theme::GREY_600);\n\n    Either::new(|data, _| data.show, finder, Empty)\n        .controller(FinderController { selector, input_id })\n}\n\nstruct FinderController {\n    selector: Selector<Find>,\n    input_id: WidgetId,\n}\n\nimpl<W> Controller<Finder, W> for FinderController\nwhere\n    W: Widget<Finder>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut Finder,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(FIND) => {\n                data.reset_matches();\n                ctx.submit_command(self.selector.with(Find {\n                    sender: ctx.widget_id(),\n                    query: FindQuery::new(&data.query),\n                }));\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(REPORT_MATCH) => {\n                if data.report_match() == data.focused_result {\n                    ctx.submit_command(FOCUS_MATCH.to(cmd.get_unchecked(REPORT_MATCH).sender));\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::TOGGLE_FINDER) => {\n                data.reset();\n                data.show = !data.show;\n                if data.show {\n                    ctx.submit_command(cmd::SET_FOCUS.to(self.input_id));\n                }\n                ctx.set_handled();\n            }\n            Event::KeyDown(k_e) if k_e.key == KbKey::Escape => {\n                data.show = false;\n            }\n            _ => {}\n        }\n        child.event(ctx, event, data, env);\n    }\n\n    fn update(\n        &mut self,\n        child: &mut W,\n        ctx: &mut UpdateCtx,\n        old_data: &Finder,\n        data: &Finder,\n        env: &Env,\n    ) {\n        if !old_data.query.same(&data.query) || !old_data.focused_result.same(&data.focused_result)\n        {\n            ctx.submit_command(FIND.to(ctx.widget_id()));\n        }\n        child.update(ctx, old_data, data, env)\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/ui/home.rs",
    "content": "use std::sync::Arc;\n\nuse druid::im::Vector;\nuse druid::widget::{Either, Flex, Label, Scroll};\nuse druid::{widget::List, LensExt, Selector, Widget, WidgetExt};\n\nuse crate::data::{Artist, Ctx, HomeDetail, MixedView, Show, Shows, Track, WithCtx};\nuse crate::ui::library::{LOAD_SHOWS, SAVE_SHOW, UNSAVE_SHOW};\nuse crate::widget::Empty;\nuse crate::{\n    data::AppState,\n    webapi::WebApi,\n    widget::{Async, MyWidgetExt},\n};\n\nuse super::{album, artist, playable, show, theme, track};\nuse super::{\n    playlist,\n    utils::{error_widget, spinner_widget},\n};\n\npub const LOAD_MADE_FOR_YOU: Selector = Selector::new(\"app.home.load-made-for-your\");\n\npub fn home_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .with_child(made_for_you())\n        .with_child(jump_back_in())\n        .with_child(user_top_mixes())\n        .with_child(recommended_stations())\n        .with_child(best_of_artists())\n        .with_child(uniquely_yours())\n        .with_child(your_shows())\n        .with_child(shows_that_you_might_like())\n        .with_child(simple_title_label(\"Your top artists\"))\n        .with_child(user_top_artists_widget())\n        .with_child(simple_title_label(\"Your top tracks\"))\n        .with_child(user_top_tracks_widget())\n}\n\nfn simple_title_label(title: &str) -> impl Widget<AppState> {\n    Flex::column().with_default_spacer().with_child(\n        Label::new(title)\n            .with_text_size(theme::grid(2.5))\n            .align_left()\n            .padding((theme::grid(1.5), 0.0)),\n    )\n}\n\nfn made_for_you() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::made_for_you),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().get_made_for_you(),\n            |_, data, q| data.home_detail.made_for_you.defer(q),\n            |_, data, r| data.home_detail.made_for_you.update(r),\n        )\n}\n\nfn recommended_stations() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::recommended_stations),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().recommended_stations(),\n            |_, data, q| data.home_detail.recommended_stations.defer(q),\n            |_, data, r| data.home_detail.recommended_stations.update(r),\n        )\n}\n\nfn uniquely_yours_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |results: &WithCtx<MixedView>, _| results.data.playlists.is_empty(),\n        Empty,\n        Flex::column()\n            .with_default_spacer()\n            .with_child(\n                Label::new(\"Uniquely yours\")\n                    .with_text_size(theme::grid(2.5))\n                    .align_left()\n                    .padding((theme::grid(1.5), theme::grid(1.5))),\n            )\n            .with_child(\n                Scroll::new(Flex::row().with_child(playlist_results_widget())).align_left(),\n            ),\n    )\n}\n\nfn uniquely_yours() -> impl Widget<AppState> {\n    Async::new(spinner_widget, uniquely_yours_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::uniquely_yours),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().uniquely_yours(),\n            |_, data, q| data.home_detail.uniquely_yours.defer(q),\n            |_, data, r| data.home_detail.uniquely_yours.update(r),\n        )\n}\n\nfn user_top_mixes() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::user_top_mixes),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().get_top_mixes(),\n            |_, data, q| data.home_detail.user_top_mixes.defer(q),\n            |_, data, r| data.home_detail.user_top_mixes.update(r),\n        )\n}\n\nfn best_of_artists() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::best_of_artists),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().best_of_artists(),\n            |_, data, q| data.home_detail.best_of_artists.defer(q),\n            |_, data, r| data.home_detail.best_of_artists.update(r),\n        )\n}\n\npub fn your_shows() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::your_shows),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().your_shows(),\n            |_, data, q| data.home_detail.your_shows.defer(q),\n            |_, data, r| data.home_detail.your_shows.update(r),\n        )\n        .on_command_async(\n            LOAD_SHOWS,\n            |_| WebApi::global().get_saved_shows().map(Shows::new),\n            |_, data, q| {\n                data.home_detail.your_shows.defer(q);\n                data.with_library_mut(|library| {\n                    library.saved_shows.defer_default();\n                });\n            },\n            |_, data, r| {\n                data.home_detail.your_shows.update((\n                    (),\n                    r.1.clone().map(|saved_shows| MixedView {\n                        title: \"Saved Shows\".into(),\n                        playlists: Vec::new().into(),\n                        artists: Vec::new().into(),\n                        albums: Vec::new().into(),\n                        shows: saved_shows.shows,\n                    }),\n                ));\n                data.with_library_mut(|library| {\n                    library.saved_shows.update(r);\n                });\n            },\n        )\n        .on_command_async(\n            SAVE_SHOW,\n            |a| WebApi::global().save_show(&a.id),\n            |_, data, s| {\n                data.with_library_mut(move |library| {\n                    library.add_show(s);\n                });\n            },\n            |_, data, (_, r)| {\n                if let Err(err) = r {\n                    data.error_alert(err);\n                } else {\n                    data.info_alert(\"Show added to library.\");\n                }\n            },\n        )\n        .on_command_async(\n            UNSAVE_SHOW,\n            |l| WebApi::global().unsave_show(&l.id),\n            |_, data, l| {\n                data.with_library_mut(|library| {\n                    library.remove_show(&l.id);\n                });\n            },\n            |_, data, (_, r)| {\n                if let Err(err) = r {\n                    data.error_alert(err);\n                } else {\n                    data.info_alert(\"Show removed from library.\");\n                }\n            },\n        )\n}\n\nfn jump_back_in() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::jump_back_in),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().jump_back_in(),\n            |_, data, q| data.home_detail.jump_back_in.defer(q),\n            |_, data, r| data.home_detail.jump_back_in.update(r),\n        )\n}\n\npub fn shows_that_you_might_like() -> impl Widget<AppState> {\n    Async::new(spinner_widget, loaded_results_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::shows_that_you_might_like),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().shows_that_you_might_like(),\n            |_, data, q| data.home_detail.shows_that_you_might_like.defer(q),\n            |_, data, r| data.home_detail.shows_that_you_might_like.update(r),\n        )\n}\n\npub fn loaded_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |results: &WithCtx<MixedView>, _| {\n            results.data.artists.is_empty()\n                && results.data.albums.is_empty()\n                && results.data.playlists.is_empty()\n                && results.data.shows.is_empty()\n        },\n        Empty,\n        Flex::column().with_child(title_label()).with_child(\n            Scroll::new(\n                Flex::row()\n                    .with_child(playlist_results_widget())\n                    .with_child(album_results_widget())\n                    .with_child(artist_results_widget())\n                    .with_child(show_results_widget()),\n            )\n            .align_left(),\n        ),\n    )\n}\n\nfn title_label() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |title_check: &Arc<str>, _| title_check.is_empty(),\n        Empty,\n        Flex::column()\n            .with_default_spacer()\n            .with_child(\n                Label::raw()\n                    .with_text_size(theme::grid(2.5))\n                    .align_left()\n                    .padding((theme::grid(1.5), theme::grid(0.5))),\n            )\n            .with_default_spacer()\n            .align_left(),\n    )\n    .lens(Ctx::data().then(MixedView::title))\n}\n\nfn artist_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |artists: &Vector<Artist>, _| artists.is_empty(),\n        Empty,\n        Scroll::new(List::new(|| artist::artist_widget(true)).horizontal())\n            .horizontal()\n            .align_left(),\n    )\n    .lens(Ctx::data().then(MixedView::artists))\n}\n\nfn album_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |playlists: &WithCtx<MixedView>, _| playlists.data.albums.is_empty(),\n        Empty,\n        Flex::column().with_child(\n            Scroll::new(List::new(|| album::album_widget(true)).horizontal())\n                .horizontal()\n                .align_left()\n                .lens(Ctx::map(MixedView::albums)),\n        ),\n    )\n}\n\nfn playlist_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |playlists: &WithCtx<MixedView>, _| playlists.data.playlists.is_empty(),\n        Empty,\n        Flex::column().with_child(\n            Scroll::new(List::new(|| playlist::playlist_widget(true)).horizontal())\n                .horizontal()\n                .align_left()\n                .lens(Ctx::map(MixedView::playlists)),\n        ),\n    )\n}\n\nfn show_results_widget() -> impl Widget<WithCtx<MixedView>> {\n    Either::new(\n        |shows: &WithCtx<Vector<Arc<Show>>>, _| shows.data.is_empty(),\n        Empty,\n        Flex::column().with_child(\n            Scroll::new(List::new(|| show::show_widget(true)).horizontal()).align_left(),\n        ),\n    )\n    .lens(Ctx::map(MixedView::shows))\n}\n\nfn user_top_artists_widget() -> impl Widget<AppState> {\n    Async::new(\n        spinner_widget,\n        || Scroll::new(List::new(|| artist::artist_widget(true)).horizontal()).horizontal(),\n        error_widget,\n    )\n    .lens(AppState::home_detail.then(HomeDetail::user_top_artists))\n    .on_command_async(\n        LOAD_MADE_FOR_YOU,\n        |_| WebApi::global().get_user_top_artist(),\n        |_, data, d| data.home_detail.user_top_artists.defer(d),\n        |_, data, r| data.home_detail.user_top_artists.update(r),\n    )\n}\n\nfn top_tracks_widget() -> impl Widget<WithCtx<Vector<Arc<Track>>>> {\n    playable::list_widget(playable::Display {\n        track: track::Display {\n            title: true,\n            album: true,\n            popularity: true,\n            cover: true,\n            ..track::Display::empty()\n        },\n    })\n}\n\nfn user_top_tracks_widget() -> impl Widget<AppState> {\n    Async::new(spinner_widget, top_tracks_widget, error_widget)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::home_detail.then(HomeDetail::user_top_tracks),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_MADE_FOR_YOU,\n            |_| WebApi::global().get_user_top_tracks(),\n            |_, data, d| data.home_detail.user_top_tracks.defer(d),\n            |_, data, r| data.home_detail.user_top_tracks.update(r),\n        )\n}\n"
  },
  {
    "path": "psst-gui/src/ui/library.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{Flex, List},\n    LensExt, Selector, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{\n        Album, AlbumLink, AppState, Ctx, Library, SavedAlbums, SavedTracks, Show, ShowLink, Track,\n        TrackId,\n    },\n    ui::home::{shows_that_you_might_like, your_shows},\n    webapi::WebApi,\n    widget::{Async, MyWidgetExt},\n};\n\nuse super::{album, playable, track, utils};\n\npub const LOAD_TRACKS: Selector = Selector::new(\"app.library.load-tracks\");\npub const LOAD_ALBUMS: Selector = Selector::new(\"app.library.load-albums\");\npub const LOAD_SHOWS: Selector = Selector::new(\"app.library.load-shows\");\n\npub const SAVE_TRACK: Selector<Arc<Track>> = Selector::new(\"app.library.save-track\");\npub const UNSAVE_TRACK: Selector<TrackId> = Selector::new(\"app.library.unsave-track\");\n\npub const SAVE_ALBUM: Selector<Arc<Album>> = Selector::new(\"app.library.save-album\");\npub const UNSAVE_ALBUM: Selector<AlbumLink> = Selector::new(\"app.library.unsave-album\");\n\npub const SAVE_SHOW: Selector<Arc<Show>> = Selector::new(\"app.library.save-show\");\npub const UNSAVE_SHOW: Selector<ShowLink> = Selector::new(\"app.library.unsave-show\");\n\npub fn saved_tracks_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        || {\n            playable::list_widget_with_find(\n                playable::Display {\n                    track: track::Display {\n                        title: true,\n                        artist: true,\n                        album: true,\n                        cover: true,\n                        ..track::Display::empty()\n                    },\n                },\n                cmd::FIND_IN_SAVED_TRACKS,\n            )\n        },\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::library.then(Library::saved_tracks.in_arc()),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_TRACKS,\n        |_| WebApi::global().get_saved_tracks().map(SavedTracks::new),\n        |_, data, _| {\n            data.with_library_mut(|library| {\n                library.saved_tracks.defer_default();\n            });\n        },\n        |_, data, r| {\n            data.with_library_mut(|library| {\n                library.saved_tracks.update(r);\n            });\n        },\n    )\n    .on_command_async(\n        SAVE_TRACK,\n        |t| WebApi::global().save_track(&t.id.0.to_base62()),\n        |_, data, t| {\n            data.with_library_mut(|library| {\n                library.add_track(t);\n            });\n        },\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Track added to library.\")\n            }\n        },\n    )\n    .on_command_async(\n        UNSAVE_TRACK,\n        |i| WebApi::global().unsave_track(&i.0.to_base62()),\n        |_, data, i| {\n            data.with_library_mut(|library| {\n                library.remove_track(&i);\n            });\n        },\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Track removed from library.\")\n            }\n        },\n    )\n}\n\npub fn saved_albums_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        || List::new(|| album::album_widget(false)).lens(Ctx::map(SavedAlbums::albums)),\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::library.then(Library::saved_albums.in_arc()),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_ALBUMS,\n        |_| WebApi::global().get_saved_albums().map(SavedAlbums::new),\n        |_, data, _| {\n            data.with_library_mut(|library| {\n                library.saved_albums.defer_default();\n            });\n        },\n        |_, data, r| {\n            data.with_library_mut(|library| {\n                library.saved_albums.update(r);\n            });\n        },\n    )\n    .on_command_async(\n        SAVE_ALBUM,\n        |a| WebApi::global().save_album(&a.id),\n        |_, data, a| {\n            data.with_library_mut(move |library| {\n                library.add_album(a);\n            });\n        },\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Album added to library.\");\n            }\n        },\n    )\n    .on_command_async(\n        UNSAVE_ALBUM,\n        |l| WebApi::global().unsave_album(&l.id),\n        |_, data, l| {\n            data.with_library_mut(|library| {\n                library.remove_album(&l.id);\n            });\n        },\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Album removed from library.\");\n            }\n        },\n    )\n}\n\npub fn saved_shows_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .with_child(your_shows())\n        .with_child(shows_that_you_might_like())\n}\n"
  },
  {
    "path": "psst-gui/src/ui/lyrics.rs",
    "content": "use druid::widget::{Container, CrossAxisAlignment, Flex, Label, LineBreaking, List, Scroll};\nuse druid::{Insets, LensExt, Selector, Widget, WidgetExt};\n\nuse crate::cmd;\nuse crate::data::{AppState, Ctx, NowPlaying, Playable, TrackLines};\nuse crate::widget::MyWidgetExt;\nuse crate::{webapi::WebApi, widget::Async};\n\nuse super::theme;\nuse super::utils;\n\npub const SHOW_LYRICS: Selector<NowPlaying> = Selector::new(\"app.home.show_lyrics\");\n\npub fn lyrics_widget() -> impl Widget<AppState> {\n    Scroll::new(\n        Container::new(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Center)\n                .with_default_spacer()\n                .with_child(track_info_widget())\n                .with_spacer(theme::grid(2.0))\n                .with_child(track_lyrics_widget()),\n        )\n        .fix_width(400.0)\n        .center(),\n    )\n    .vertical()\n}\n\nfn track_info_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Center)\n        .with_child(\n            Label::dynamic(|data: &AppState, _| {\n                data.playback.now_playing.as_ref().map_or_else(\n                    || \"No track playing\".to_string(),\n                    |np| match &np.item {\n                        Playable::Track(track) => track.name.clone().to_string(),\n                        _ => \"Unknown track\".to_string(),\n                    },\n                )\n            })\n            .with_font(theme::UI_FONT_MEDIUM)\n            .with_text_size(theme::TEXT_SIZE_LARGE),\n        )\n        .with_spacer(theme::grid(0.5))\n        .with_child(\n            Label::dynamic(|data: &AppState, _| {\n                data.playback.now_playing.as_ref().map_or_else(\n                    || \"\".to_string(),\n                    |np| match &np.item {\n                        Playable::Track(track) => {\n                            format!(\"{} - {}\", track.artist_name(), track.album_name())\n                        }\n                        _ => \"\".to_string(),\n                    },\n                )\n            })\n            .with_text_size(theme::TEXT_SIZE_SMALL)\n            .with_text_color(theme::PLACEHOLDER_COLOR),\n        )\n}\n\nfn track_lyrics_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        || {\n            List::new(|| {\n                Label::raw()\n                    .with_line_break_mode(LineBreaking::WordWrap)\n                    .lens(Ctx::data().then(TrackLines::words))\n                    .expand_width()\n                    .center()\n                    .padding(Insets::uniform_xy(theme::grid(1.0), theme::grid(0.5)))\n                    .link()\n                    .rounded(theme::BUTTON_BORDER_RADIUS)\n                    .on_left_click(|ctx, _, c, _| {\n                        if c.data.start_time_ms.parse::<u64>().unwrap() != 0 {\n                            ctx.submit_command(\n                                cmd::SKIP_TO_POSITION\n                                    .with(c.data.start_time_ms.parse::<u64>().unwrap()),\n                            )\n                        }\n                    })\n            })\n        },\n        || Label::new(\"No lyrics found for this track\").center(),\n    )\n    .lens(Ctx::make(AppState::common_ctx, AppState::lyrics).then(Ctx::in_promise()))\n    .on_command_async(\n        SHOW_LYRICS,\n        |t| WebApi::global().get_lyrics(t.item.id().to_base62()),\n        |_, data, _| data.lyrics.defer(()),\n        |_, data, r| data.lyrics.update(((), r.1)),\n    )\n}\n"
  },
  {
    "path": "psst-gui/src/ui/menu.rs",
    "content": "use druid::{commands, platform_menus, Env, LocalizedString, Menu, MenuItem, SysMods, WindowId};\n\nuse crate::{\n    cmd,\n    data::{AppState, Nav},\n};\n\npub fn main_menu(_window: Option<WindowId>, _data: &AppState, _env: &Env) -> Menu<AppState> {\n    if cfg!(target_os = \"macos\") {\n        Menu::empty().entry(mac_app_menu())\n    } else {\n        Menu::empty()\n    }\n    .entry(edit_menu())\n    .entry(view_menu())\n}\n\nfn mac_app_menu() -> Menu<AppState> {\n    // macOS-only commands are deprecated on other systems.\n    #[cfg_attr(not(target_os = \"macos\"), allow(deprecated))]\n    Menu::new(LocalizedString::new(\"macos-menu-application-menu\"))\n        .entry(platform_menus::mac::application::preferences())\n        .separator()\n        .entry(\n            // TODO:\n            //  This is just overriding `platform_menus::mac::application::quit()`\n            //  because l10n is a bit stupid now.\n            MenuItem::new(LocalizedString::new(\"macos-menu-quit\").with_placeholder(\"Quit Psst\"))\n                .command(cmd::QUIT_APP_WITH_SAVE)\n                .hotkey(SysMods::Cmd, \"q\"),\n        )\n        .entry(\n            MenuItem::new(LocalizedString::new(\"macos-menu-hide\").with_placeholder(\"Hide Psst\"))\n                .command(commands::HIDE_APPLICATION)\n                .hotkey(SysMods::Cmd, \"h\"),\n        )\n        .entry(\n            MenuItem::new(\n                LocalizedString::new(\"macos-menu-hide-others\").with_placeholder(\"Hide Others\"),\n            )\n            .command(commands::HIDE_OTHERS)\n            .hotkey(SysMods::AltCmd, \"h\"),\n        )\n}\n\nfn edit_menu() -> Menu<AppState> {\n    Menu::new(LocalizedString::new(\"common-menu-edit-menu\").with_placeholder(\"Edit\"))\n        .entry(platform_menus::common::cut())\n        .entry(platform_menus::common::copy())\n        .entry(platform_menus::common::paste())\n}\n\nfn view_menu() -> Menu<AppState> {\n    Menu::new(LocalizedString::new(\"menu-view-menu\").with_placeholder(\"View\"))\n        .entry(\n            MenuItem::new(LocalizedString::new(\"menu-item-home\").with_placeholder(\"Home\"))\n                .command(cmd::NAVIGATE.with(Nav::Home))\n                .hotkey(SysMods::Cmd, \"1\"),\n        )\n        .entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-saved-tracks\").with_placeholder(\"Saved Tracks\"),\n            )\n            .command(cmd::NAVIGATE.with(Nav::SavedTracks))\n            .hotkey(SysMods::Cmd, \"2\"),\n        )\n        .entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-saved-albums\").with_placeholder(\"Saved Albums\"),\n            )\n            .command(cmd::NAVIGATE.with(Nav::SavedAlbums))\n            .hotkey(SysMods::Cmd, \"3\"),\n        )\n        .entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-saved-shows\").with_placeholder(\"Saved Shows\"),\n            )\n            .command(cmd::NAVIGATE.with(Nav::Shows))\n            .hotkey(SysMods::Cmd, \"4\"),\n        )\n        .entry(\n            MenuItem::new(LocalizedString::new(\"menu-item-search\").with_placeholder(\"Search...\"))\n                .command(cmd::SET_FOCUS.to(cmd::WIDGET_SEARCH_INPUT))\n                .hotkey(SysMods::Cmd, \"l\"),\n        )\n        .entry(\n            MenuItem::new(LocalizedString::new(\"menu-item-find\").with_placeholder(\"Find...\"))\n                .command(cmd::TOGGLE_FINDER)\n                .hotkey(SysMods::Cmd, \"f\"),\n        )\n}\n"
  },
  {
    "path": "psst-gui/src/ui/mod.rs",
    "content": "use crate::data::config::SortCriteria;\nuse crate::data::Track;\nuse crate::error::Error;\nuse crate::{\n    cmd,\n    controller::{\n        AfterDelay, AlertCleanupController, NavController, SessionController, SortController,\n    },\n    data::{\n        config::SortOrder, Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route,\n        ALERT_DURATION,\n    },\n    webapi::WebApi,\n    widget::{\n        icons, icons::SvgIcon, Border, Empty, MyWidgetExt, Overlay, RemoteImage, ThemeScope,\n        ViewDispatcher,\n    },\n};\nuse credits::TrackCredits;\nuse druid::widget::Controller;\nuse druid::KbKey;\nuse druid::{\n    im::Vector,\n    widget::{\n        CrossAxisAlignment, Either, Flex, Label, LineBreaking, List, Scroll, Slider, Split,\n        ViewSwitcher,\n    },\n    Color, Env, Insets, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, WindowDesc,\n};\nuse druid_shell::Cursor;\nuse std::sync::Arc;\nuse std::time::Duration;\n\npub mod album;\npub mod artist;\npub mod credits;\npub mod episode;\npub mod find;\npub mod home;\npub mod library;\npub mod lyrics;\npub mod menu;\npub mod playable;\npub mod playback;\npub mod playlist;\npub mod preferences;\npub mod recommend;\npub mod search;\npub mod show;\npub mod theme;\npub mod track;\npub mod user;\npub mod utils;\n\npub const DOWNLOAD_ARTWORK: Selector<(String, String)> = Selector::new(\"app.artwork.download\");\n\npub fn main_window(config: &Config) -> WindowDesc<AppState> {\n    let win = WindowDesc::new(root_widget())\n        .title(compute_main_window_title)\n        .with_min_size((theme::grid(65.0), theme::grid(50.0)))\n        .window_size(config.window_size)\n        .show_title(false)\n        .transparent_titlebar(true);\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\npub fn preferences_window() -> WindowDesc<AppState> {\n    let win_size = (theme::grid(50.0), theme::grid(55.0));\n\n    // On Windows, the window size includes the titlebar.\n    let win_size = if cfg!(target_os = \"windows\") {\n        const WINDOWS_TITLEBAR_OFFSET: f64 = 56.0;\n        (win_size.0, win_size.1 + WINDOWS_TITLEBAR_OFFSET)\n    } else {\n        win_size\n    };\n\n    let win = WindowDesc::new(preferences_widget())\n        .title(\"Preferences\")\n        .window_size(win_size)\n        .resizable(false)\n        .show_title(false)\n        .transparent_titlebar(true);\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\npub fn account_setup_window() -> WindowDesc<AppState> {\n    let win = WindowDesc::new(account_setup_widget())\n        .title(\"Login\")\n        .window_size((theme::grid(50.0), theme::grid(45.0)))\n        .resizable(false)\n        .show_title(false)\n        .transparent_titlebar(true);\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\npub fn artwork_window() -> WindowDesc<AppState> {\n    let win_size = (theme::grid(50.0), theme::grid(50.0));\n\n    // On Windows, the window size includes the titlebar, so we need to account for it\n    let win_size = if cfg!(target_os = \"windows\") {\n        const WINDOWS_TITLEBAR_OFFSET: f64 = 24.0; // Standard Windows titlebar height\n        (win_size.0, win_size.1 + WINDOWS_TITLEBAR_OFFSET)\n    } else {\n        win_size\n    };\n\n    let win = WindowDesc::new(artwork_widget())\n        .window_size(win_size)\n        .resizable(false)\n        .show_title(false)\n        .transparent_titlebar(true)\n        .title(|data: &AppState, _env: &_| {\n            data.playback\n                .now_playing\n                .as_ref()\n                .map(|np| match &np.item {\n                    Playable::Track(track) => {\n                        format!(\"{} - {}\", track.album_name(), track.artist_name())\n                    }\n                    Playable::Episode(episode) => episode.name.to_string(),\n                })\n                .unwrap_or_else(|| \"Now Playing\".to_string())\n        });\n\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\nfn preferences_widget() -> impl Widget<AppState> {\n    ThemeScope::new(\n        preferences::preferences_widget()\n            .background(theme::BACKGROUND_DARK)\n            .expand(),\n    )\n}\n\nfn account_setup_widget() -> impl Widget<AppState> {\n    ThemeScope::new(\n        preferences::account_setup_widget()\n            .background(theme::BACKGROUND_DARK)\n            .expand(),\n    )\n}\n\nstruct ArtworkController;\n\nimpl<W: Widget<AppState>> Controller<AppState, W> for ArtworkController {\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut druid::EventCtx,\n        event: &druid::Event,\n        data: &mut AppState,\n        env: &druid::Env,\n    ) {\n        if let druid::Event::WindowConnected = event {\n            ctx.request_focus();\n            ctx.set_handled();\n        }\n\n        if let druid::Event::KeyDown(key_event) = event {\n            // Handle D key for download\n            if key_event.key == KbKey::Character('d'.into()) {\n                if let Some(np) = &data.playback.now_playing {\n                    if let Some((url, _)) = np.cover_image_metadata() {\n                        let title = match &np.item {\n                            Playable::Track(track) => track\n                                .album\n                                .as_ref()\n                                .map(|a| a.name.as_ref())\n                                .unwrap_or(\"Unknown Album\"),\n                            Playable::Episode(episode) => episode.show.name.as_ref(),\n                        };\n                        ctx.submit_command(\n                            DOWNLOAD_ARTWORK.with((url.to_string(), title.to_string())),\n                        );\n                        ctx.set_handled();\n                    }\n                }\n            }\n        }\n        child.event(ctx, event, data, env);\n    }\n}\n\npub fn artwork_widget() -> impl Widget<AppState> {\n    RemoteImage::new(utils::placeholder_widget(), move |data: &AppState, _| {\n        data.playback\n            .now_playing\n            .as_ref()\n            .and_then(|np| np.cover_image_url(512.0, 512.0))\n            .map(|url| url.into())\n    })\n    .expand()\n    .background(theme::BACKGROUND_DARK)\n    .controller(ArtworkController)\n}\n\nfn root_widget() -> impl Widget<AppState> {\n    let playlists = Scroll::new(playlist::list_widget())\n        .vertical()\n        .expand_height();\n\n    let playlists = Flex::column()\n        .must_fill_main_axis(true)\n        .with_child(sidebar_menu_widget())\n        .with_default_spacer()\n        .with_flex_child(playlists, 1.0)\n        .padding(if cfg!(target_os = \"macos\") {\n            // Accommodate the window controls on Mac.\n            Insets::new(0.0, 24.0, 0.0, 0.0)\n        } else {\n            Insets::ZERO\n        });\n\n    let controls = Flex::column()\n        .with_default_spacer()\n        .with_child(volume_slider())\n        .with_default_spacer()\n        .with_child(user::user_widget())\n        .center()\n        .fix_height(88.0)\n        .background(Border::Top.with_color(theme::GREY_500));\n\n    let sidebar = Flex::column()\n        .with_flex_child(playlists, 1.0)\n        .with_child(controls)\n        .background(theme::BACKGROUND_DARK);\n\n    let topbar = Flex::row()\n        .must_fill_main_axis(true)\n        .with_child(topbar_back_button_widget())\n        .with_flex_child(topbar_title_widget(), 1.0)\n        .with_child(topbar_sort_widget())\n        .background(Border::Bottom.with_color(theme::BACKGROUND_DARK));\n\n    let main = Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(topbar)\n        .with_flex_child(Overlay::bottom(route_widget(), alert_widget()), 1.0)\n        .with_child(playback::panel_widget())\n        .background(theme::BACKGROUND_LIGHT);\n\n    let split = Split::columns(sidebar, main)\n        .split_point(0.2)\n        .bar_size(1.0)\n        .min_size(150.0, 300.0)\n        .min_bar_area(1.0)\n        .solid_bar(true);\n\n    ThemeScope::new(split)\n        .controller(SessionController)\n        .controller(NavController)\n        .controller(SortController)\n        .on_command_async(\n            cmd::LOAD_TRACK_CREDITS,\n            |track: Arc<Track>| {\n                log::debug!(\"fetching credits for track: {}\", track.name);\n                WebApi::global().get_track_credits(&track.id.0.to_base62())\n            },\n            |_, data: &mut AppState, _| {\n                data.credits = None;\n            },\n            |_ctx, data, (_track, result): (Arc<Track>, Result<TrackCredits, Error>)| match result {\n                Ok(credits) => {\n                    data.credits = Some(credits);\n                }\n                Err(err) => {\n                    log::error!(\"Failed to fetch credits for {}: {:?}\", _track.name, err);\n                    data.error_alert(format!(\"Failed to fetch track credits: {err}\"));\n                }\n            },\n        )\n    // .debug_invalidation()\n    // .debug_widget_id()\n    // .debug_paint_layout()\n}\n\nfn alert_widget() -> impl Widget<AppState> {\n    const BG: Key<Color> = Key::new(\"app.alert.BG\");\n    const DISMISS_ALERT: Selector<usize> = Selector::new(\"app.alert.dismiss\");\n\n    List::new(|| {\n        Flex::row()\n            .with_child(\n                Label::dynamic(|alert: &Alert, _| match alert.style {\n                    AlertStyle::Error => \"Error:\".to_string(),\n                    AlertStyle::Info => String::new(),\n                })\n                .with_font(theme::UI_FONT_MEDIUM),\n            )\n            .with_default_spacer()\n            .with_flex_child(Label::raw().lens(Alert::message), 1.0)\n            .padding(theme::grid(2.0))\n            .background(BG)\n            .env_scope(|env, alert: &Alert| {\n                env.set(\n                    BG,\n                    match alert.style {\n                        AlertStyle::Error => env.get(theme::RED),\n                        AlertStyle::Info => env.get(theme::GREY_600),\n                    },\n                )\n            })\n            .controller(AfterDelay::new(\n                ALERT_DURATION,\n                |ctx, alert: &mut Alert, _| {\n                    ctx.submit_command(DISMISS_ALERT.with(alert.id));\n                },\n            ))\n    })\n    .lens(AppState::alerts)\n    .on_command(DISMISS_ALERT, |_, &id, state| {\n        state.dismiss_alert(id);\n    })\n    .controller(AlertCleanupController)\n}\n\nfn route_widget() -> impl Widget<AppState> {\n    ViewDispatcher::new(\n        |state: &AppState, _| state.nav.route(),\n        |route: &Route, _, _| match route {\n            Route::Home => Scroll::new(home::home_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::Lyrics => Scroll::new(lyrics::lyrics_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::SavedTracks => Flex::column()\n                .with_child(\n                    find::finder_widget(cmd::FIND_IN_SAVED_TRACKS, \"Find in Saved Tracks...\")\n                        .lens(AppState::finder),\n                )\n                .with_flex_child(\n                    Scroll::new(library::saved_tracks_widget().padding(theme::grid(1.0)))\n                        .vertical(),\n                    1.0,\n                )\n                .boxed(),\n            Route::SavedAlbums => {\n                Scroll::new(library::saved_albums_widget().padding(theme::grid(1.0)))\n                    .vertical()\n                    .boxed()\n            }\n            Route::Shows => Scroll::new(library::saved_shows_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::SearchResults => search::results_widget().padding(theme::grid(1.0)).boxed(),\n            Route::AlbumDetail => Scroll::new(album::detail_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::ArtistDetail => Scroll::new(artist::detail_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::PlaylistDetail => Flex::column()\n                .with_child(\n                    find::finder_widget(cmd::FIND_IN_PLAYLIST, \"Find in Playlist...\")\n                        .lens(AppState::finder),\n                )\n                .with_flex_child(\n                    Scroll::new(playlist::detail_widget().padding(theme::grid(1.0))).vertical(),\n                    1.0,\n                )\n                .boxed(),\n            Route::ShowDetail => Scroll::new(show::detail_widget().padding(theme::grid(1.0)))\n                .vertical()\n                .boxed(),\n            Route::Recommendations => {\n                Scroll::new(recommend::results_widget().padding(theme::grid(1.0)))\n                    .vertical()\n                    .boxed()\n            }\n        },\n    )\n    .expand()\n}\n\nfn sidebar_menu_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .with_default_spacer()\n        .with_child(sidebar_link_widget(\"Home\", Some(&icons::HOME), Nav::Home))\n        .with_child(sidebar_link_widget(\n            \"Tracks\",\n            Some(&icons::MUSIC_NOTE),\n            Nav::SavedTracks,\n        ))\n        .with_child(sidebar_link_widget(\n            \"Albums\",\n            Some(&icons::ALBUM),\n            Nav::SavedAlbums,\n        ))\n        .with_child(sidebar_link_widget(\n            \"Podcasts\",\n            Some(&icons::PODCAST),\n            Nav::Shows,\n        ))\n        .with_child(search::input_widget().padding((theme::grid(1.0), theme::grid(1.0))))\n}\n\nfn sidebar_link_widget(\n    title: &str,\n    icon: Option<&icons::SvgIcon>,\n    link_nav: Nav,\n) -> impl Widget<AppState> {\n    Flex::row()\n        .with_child(\n            icon.map(|i| {\n                i.scale((18.0, 18.0))\n                    .padding_right(theme::grid(1.0))\n                    .boxed()\n            })\n            .unwrap_or_else(|| Empty.boxed()),\n        )\n        .with_child(Label::new(title))\n        .with_flex_spacer(1.0)\n        .padding((theme::grid(2.0), theme::grid(1.0)))\n        .expand_width()\n        .link()\n        .env_scope({\n            let link_nav = link_nav.clone();\n            move |env, nav: &Nav| {\n                env.set(\n                    theme::LINK_COLD_COLOR,\n                    if &link_nav == nav {\n                        env.get(theme::MENU_BUTTON_BG_ACTIVE)\n                    } else {\n                        env.get(theme::MENU_BUTTON_BG_INACTIVE)\n                    },\n                );\n                env.set(\n                    theme::TEXT_COLOR,\n                    if &link_nav == nav {\n                        env.get(theme::MENU_BUTTON_FG_ACTIVE)\n                    } else {\n                        env.get(theme::MENU_BUTTON_FG_INACTIVE)\n                    },\n                );\n            }\n        })\n        .on_left_click(move |ctx, _, _, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(link_nav.clone()));\n        })\n        .lens(AppState::nav)\n}\n\nfn volume_slider() -> impl Widget<AppState> {\n    const SAVE_DELAY: Duration = Duration::from_millis(100);\n    const SAVE_TO_CONFIG: Selector = Selector::new(\"app.volume.save-to-config\");\n\n    Flex::row()\n        .with_flex_child(\n            Slider::new()\n                .with_range(0.0, 1.0)\n                .expand_width()\n                .env_scope(|env, _| {\n                    env.set(theme::BASIC_WIDGET_HEIGHT, theme::grid(1.5));\n                    env.set(theme::FOREGROUND_LIGHT, env.get(theme::GREY_400));\n                    env.set(theme::FOREGROUND_DARK, env.get(theme::GREY_400));\n                })\n                .with_cursor(Cursor::Pointer),\n            1.0,\n        )\n        .with_default_spacer()\n        .with_child(\n            Label::dynamic(|&volume: &f64, _| format!(\"{}%\", (volume * 100.0).floor()))\n                .with_text_color(theme::PLACEHOLDER_COLOR)\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .fix_width(theme::grid(4.0)),\n        )\n        .padding((theme::grid(2.0), 0.0))\n        .on_debounce(SAVE_DELAY, |ctx, _, _| ctx.submit_command(SAVE_TO_CONFIG))\n        .lens(AppState::playback.then(Playback::volume))\n        .on_scroll(\n            |data| &data.config.slider_scroll_scale,\n            |_, data, _, scaled_delta| {\n                data.playback.volume = (data.playback.volume + scaled_delta).clamp(0.0, 1.0);\n            },\n        )\n}\n\nfn topbar_sort_widget() -> impl Widget<AppState> {\n    let up_icon = icons::UP.scale((10.0, theme::grid(2.0)));\n    let down_icon = icons::DOWN.scale((10.0, theme::grid(2.0)));\n\n    let ascending_icon = up_icon\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, _, _| {\n            ctx.submit_command(cmd::TOGGLE_SORT_ORDER);\n        })\n        .context_menu(sorting_menu);\n\n    let descending_icon = down_icon\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, _, _| {\n            ctx.submit_command(cmd::TOGGLE_SORT_ORDER);\n        })\n        .context_menu(sorting_menu);\n    let enabled = Either::new(\n        |data: &AppState, _| {\n            // check if the current nav is PlaylistDetail\n            data.config.sort_order == SortOrder::Ascending\n        },\n        ascending_icon,\n        descending_icon,\n    );\n\n    //a \"dynamic\" widget that is always disabled.\n    let disabled = Either::new(|_, _| true, Empty.boxed(), Empty.boxed());\n\n    Either::new(\n        |nav: &AppState, _| {\n            // check if the current nav is PlaylistDetail\n            matches!(nav.nav, Nav::PlaylistDetail(_))\n        },\n        enabled,\n        disabled,\n    )\n    .padding(theme::grid(1.0)) //.lens(AppState::nav)\n}\n\nfn topbar_back_button_widget() -> impl Widget<AppState> {\n    let icon = icons::BACK.scale((10.0, theme::grid(2.0)));\n    let disabled = icon\n        .clone()\n        .with_color(theme::GREY_600)\n        .padding(theme::grid(1.0));\n    let enabled = icon\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, _, _| {\n            ctx.submit_command(cmd::NAVIGATE_BACK.with(1));\n        })\n        .context_menu(history_menu);\n    Either::new(\n        |history: &Vector<Nav>, _| history.is_empty(),\n        disabled,\n        enabled,\n    )\n    .padding(theme::grid(1.0))\n    .lens(AppState::history)\n}\n\nfn history_menu(history: &Vector<Nav>) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    for (index, history) in history.iter().rev().take(10).enumerate() {\n        let skip_back_in_history_n_times = index + 1;\n        menu = menu.entry(\n            MenuItem::new(history.full_title())\n                .command(cmd::NAVIGATE_BACK.with(skip_back_in_history_n_times)),\n        );\n    }\n\n    menu\n}\n\nfn sorting_menu(app_state: &AppState) -> Menu<AppState> {\n    let mut menu = Menu::new(\"Sort by\");\n\n    // Create menu items for sorting options\n    let mut sort_by_title = MenuItem::new(\"Title\").command(cmd::SORT_BY_TITLE);\n    let mut sort_by_album = MenuItem::new(\"Album\").command(cmd::SORT_BY_ALBUM);\n    let mut sort_by_date_added = MenuItem::new(\"Date Added\").command(cmd::SORT_BY_DATE_ADDED);\n    let mut sort_by_duration = MenuItem::new(\"Duration\").command(cmd::SORT_BY_DURATION);\n    let mut sort_by_artist = MenuItem::new(\"Artist\").command(cmd::SORT_BY_ARTIST);\n\n    match app_state.config.sort_criteria {\n        SortCriteria::Title => sort_by_title = sort_by_title.selected(true),\n        SortCriteria::Album => sort_by_album = sort_by_album.selected(true),\n        SortCriteria::DateAdded => sort_by_date_added = sort_by_date_added.selected(true),\n        SortCriteria::Duration => sort_by_duration = sort_by_duration.selected(true),\n        SortCriteria::Artist => sort_by_artist = sort_by_artist.selected(true),\n    };\n\n    // Add the items and checkboxes to the menu\n    menu = menu.entry(sort_by_album);\n    menu = menu.entry(sort_by_artist);\n    menu = menu.entry(sort_by_date_added);\n    menu = menu.entry(sort_by_duration);\n    menu = menu.entry(sort_by_title);\n\n    menu\n}\n\nfn topbar_title_widget() -> impl Widget<AppState> {\n    Flex::row()\n        .cross_axis_alignment(CrossAxisAlignment::Center)\n        .with_flex_child(route_title_widget(), 1.0)\n        .with_spacer(theme::grid(0.5))\n        .with_child(route_icon_widget())\n        .lens(AppState::nav)\n}\n\nfn route_icon_widget() -> impl Widget<Nav> {\n    ViewSwitcher::new(\n        |nav: &Nav, _| nav.clone(),\n        |nav: &Nav, _, _| {\n            let icon = |icon: &SvgIcon| icon.scale(theme::ICON_SIZE_MEDIUM);\n            match &nav {\n                Nav::Home | Nav::Lyrics | Nav::SavedTracks | Nav::SavedAlbums | Nav::Shows => {\n                    Empty.boxed()\n                }\n                Nav::SearchResults(_) | Nav::Recommendations(_) => icon(&icons::SEARCH).boxed(),\n                Nav::AlbumDetail(_, _) => icon(&icons::ALBUM).boxed(),\n                Nav::ArtistDetail(_) => icon(&icons::ARTIST).boxed(),\n                Nav::PlaylistDetail(_) => icon(&icons::PLAYLIST).boxed(),\n                Nav::ShowDetail(_) => icon(&icons::PODCAST).boxed(),\n            }\n        },\n    )\n}\n\nfn route_title_widget() -> impl Widget<Nav> {\n    Label::dynamic(|nav: &Nav, _| nav.title())\n        .with_line_break_mode(LineBreaking::Clip)\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_text_size(theme::TEXT_SIZE_LARGE)\n}\n\nfn compute_main_window_title(data: &AppState, _env: &Env) -> String {\n    if let Some(now_playing) = &data.playback.now_playing {\n        match &now_playing.item {\n            Playable::Track(track) => {\n                format!(\"{} - {}\", track.artist_name(), track.name)\n            }\n            Playable::Episode(episode) => episode.name.to_string(),\n        }\n    } else {\n        \"Psst\".to_owned()\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/ui/playable.rs",
    "content": "use std::{mem, sync::Arc};\n\nuse druid::{\n    im::Vector,\n    kurbo::Line,\n    lens::Map,\n    piet::StrokeStyle,\n    widget::{Controller, ControllerHost, List, ListIter, Painter, ViewSwitcher},\n    Data, Env, Event, EventCtx, Lens, RenderContext, Selector, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{\n        ArtistTracks, CommonCtx, FindQuery, MatchFindQuery, Playable, PlaybackOrigin,\n        PlaybackPayload, PlaylistTracks, Recommendations, SavedTracks, SearchResults, ShowEpisodes,\n        Track, WithCtx,\n    },\n    ui::theme,\n};\n\nuse super::{\n    episode,\n    find::{Find, Findable},\n    track,\n};\n\n#[derive(Copy, Clone)]\npub struct Display {\n    pub track: track::Display,\n}\n\npub fn list_widget<T>(display: Display) -> impl Widget<WithCtx<T>>\nwhere\n    T: PlayableIter + Data,\n{\n    ControllerHost::new(List::new(move || playable_widget(display)), PlayController)\n}\n\npub fn list_widget_with_find<T>(\n    display: Display,\n    selector: Selector<Find>,\n) -> impl Widget<WithCtx<T>>\nwhere\n    T: PlayableIter + Data,\n{\n    ControllerHost::new(\n        List::new(move || Findable::new(playable_widget(display), selector)),\n        PlayController,\n    )\n}\n\nfn playable_widget(display: Display) -> impl Widget<PlayRow<Playable>> {\n    ViewSwitcher::new(\n        |row: &PlayRow<Playable>, _| mem::discriminant(&row.item),\n        move |_, row: &PlayRow<Playable>, _| match row.item.clone() {\n            // TODO: Do the lenses some other way.\n            Playable::Track(track) => track::playable_widget(&track, display.track)\n                .lens(Map::new(\n                    move |pb: &PlayRow<Playable>| pb.with(track.clone()),\n                    |_, _| {\n                        // Ignore mutation.\n                    },\n                ))\n                .boxed(),\n            Playable::Episode(episode) => {\n                episode::playable_widget()\n                    .lens(Map::new(\n                        move |pb: &PlayRow<Playable>| pb.with(episode.clone()),\n                        |_, _| {\n                            // Ignore mutation.\n                        },\n                    ))\n                    .boxed()\n            }\n        },\n    )\n}\n\npub fn is_playing_marker_widget() -> impl Widget<bool> {\n    Painter::new(|ctx, is_playing, env| {\n        const STYLE: StrokeStyle = StrokeStyle::new().dash_pattern(&[1.0, 2.0]);\n\n        let y = ctx.size().height / 2.0;\n        let line = Line::new((0.0, y), (ctx.size().width, y));\n        let color = if *is_playing {\n            env.get(theme::GREY_300)\n        } else {\n            env.get(theme::GREY_500)\n        };\n        ctx.stroke_styled(line, &color, 1.0, &STYLE);\n    })\n}\n\n#[derive(Clone, Data, Lens)]\npub struct PlayRow<T> {\n    pub item: T,\n    pub ctx: Arc<CommonCtx>,\n    pub origin: Arc<PlaybackOrigin>,\n    pub position: usize,\n    pub is_playing: bool,\n}\n\nimpl<T> PlayRow<T> {\n    fn with<U>(&self, item: U) -> PlayRow<U> {\n        PlayRow {\n            item,\n            ctx: self.ctx.clone(),\n            origin: self.origin.clone(),\n            position: self.position,\n            is_playing: self.is_playing,\n        }\n    }\n}\n\nimpl MatchFindQuery for PlayRow<Playable> {\n    fn matches_query(&self, q: &FindQuery) -> bool {\n        match &self.item {\n            Playable::Track(track) => {\n                q.matches_str(&track.name)\n                    || track.album.iter().any(|a| q.matches_str(&a.name))\n                    || track.artists.iter().any(|a| q.matches_str(&a.name))\n            }\n            Playable::Episode(episode) => {\n                q.matches_str(&episode.name)\n                    || q.matches_str(&episode.description)\n                    || q.matches_str(&episode.show.name)\n            }\n        }\n    }\n}\n\npub trait PlayableIter {\n    fn origin(&self) -> PlaybackOrigin;\n    fn count(&self) -> usize;\n    fn for_each(&self, cb: impl FnMut(Playable, usize));\n}\n\n// This should change to a more specific name as it could be confusing for others\n// As at the moment this is only used for the home page!\nimpl PlayableIter for Vector<Arc<Track>> {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Home\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.len()\n    }\n}\n\nimpl PlayableIter for PlaylistTracks {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Playlist(self.link())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.tracks.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n\nimpl PlayableIter for ArtistTracks {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Artist(self.link())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.tracks.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n\nimpl PlayableIter for SavedTracks {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Library\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.tracks.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n\nimpl PlayableIter for SearchResults {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Search(self.query.clone())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.tracks.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n\nimpl PlayableIter for Recommendations {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Recommendations(self.request.clone())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, track) in self.tracks.iter().enumerate() {\n            cb(Playable::Track(track.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.tracks.len()\n    }\n}\n\nimpl PlayableIter for ShowEpisodes {\n    fn origin(&self) -> PlaybackOrigin {\n        PlaybackOrigin::Show(self.show.clone())\n    }\n\n    fn for_each(&self, mut cb: impl FnMut(Playable, usize)) {\n        for (position, episode) in self.episodes.iter().enumerate() {\n            cb(Playable::Episode(episode.to_owned()), position);\n        }\n    }\n\n    fn count(&self) -> usize {\n        self.episodes.len()\n    }\n}\n\nimpl<T> ListIter<PlayRow<Playable>> for WithCtx<T>\nwhere\n    T: PlayableIter + Data,\n{\n    fn for_each(&self, mut cb: impl FnMut(&PlayRow<Playable>, usize)) {\n        let origin = Arc::new(self.data.origin());\n        self.data.for_each(|item, position| {\n            cb(\n                &PlayRow {\n                    is_playing: self.ctx.is_playing(&item),\n                    ctx: self.ctx.to_owned(),\n                    origin: origin.clone(),\n                    item,\n                    position,\n                },\n                position,\n            )\n        });\n    }\n\n    fn for_each_mut(&mut self, mut cb: impl FnMut(&mut PlayRow<Playable>, usize)) {\n        let origin = Arc::new(self.data.origin());\n        self.data.for_each(|item, position| {\n            cb(\n                &mut PlayRow {\n                    is_playing: self.ctx.is_playing(&item),\n                    ctx: self.ctx.to_owned(),\n                    origin: origin.clone(),\n                    item,\n                    position,\n                },\n                position,\n            )\n        });\n    }\n\n    fn data_len(&self) -> usize {\n        self.data.count()\n    }\n}\n\nstruct PlayController;\n\nimpl<T, W> Controller<WithCtx<T>, W> for PlayController\nwhere\n    T: PlayableIter + Data,\n    W: Widget<WithCtx<T>>,\n{\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut WithCtx<T>,\n        env: &Env,\n    ) {\n        match event {\n            Event::Notification(note) => {\n                if let Some(position) = note.get(cmd::PLAY) {\n                    let mut items = Vector::new();\n                    data.data.for_each(|item, _| items.push_back(item));\n                    let payload = PlaybackPayload {\n                        items,\n                        origin: data.data.origin(),\n                        position: position.to_owned(),\n                    };\n                    ctx.submit_command(cmd::PLAY_TRACKS.with(payload));\n                    ctx.set_handled();\n                }\n            }\n            _ => child.event(ctx, event, data, env),\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/ui/playback.rs",
    "content": "use std::time::Duration;\n\nuse druid::{\n    kurbo::{Affine, BezPath},\n    widget::{CrossAxisAlignment, Either, Flex, Label, LineBreaking, Spinner, ViewSwitcher},\n    BoxConstraints, Cursor, Data, Env, Event, EventCtx, LayoutCtx, LensExt, LifeCycle,\n    LifeCycleCtx, MouseButton, PaintCtx, Point, Rect, RenderContext, Size, UpdateCtx, Widget,\n    WidgetExt, WidgetPod,\n};\nuse itertools::Itertools;\n\nuse crate::{\n    cmd::{self, ADD_TO_QUEUE, SHOW_ARTWORK, TOGGLE_LYRICS},\n    controller::PlaybackController,\n    data::{\n        AppState, AudioAnalysis, Episode, NowPlaying, Playable, PlayableMatcher, Playback,\n        PlaybackOrigin, PlaybackState, QueueBehavior, ShowLink, Track,\n    },\n    widget::{\n        icons::{self, SvgIcon},\n        Empty, Maybe, MyWidgetExt, RemoteImage,\n    },\n};\n\nuse super::{episode, library, theme, track, utils};\n\npub fn panel_widget() -> impl Widget<AppState> {\n    let seek_bar = Maybe::or_empty(SeekBar::new).lens(Playback::now_playing);\n    let item_info = Maybe::or_empty(playing_item_widget).lens(Playback::now_playing);\n    let controls = Either::new(\n        |playback, _| playback.now_playing.is_some(),\n        player_widget(),\n        Empty,\n    );\n    Flex::column()\n        .with_child(seek_bar)\n        .with_child(BarLayout::new(item_info, controls))\n        .lens(AppState::playback)\n        .controller(PlaybackController::new())\n        .on_command(ADD_TO_QUEUE, |_, _, data| {\n            data.info_alert(\"Track added to queue.\")\n        })\n}\n\nfn playing_item_widget() -> impl Widget<NowPlaying> {\n    let cover_art = cover_widget(theme::grid(8.0));\n\n    let name = PlayableMatcher::new()\n        .track(\n            Label::raw()\n                .with_line_break_mode(LineBreaking::Clip)\n                .with_font(theme::UI_FONT_MEDIUM)\n                .lens(Track::name.in_arc()),\n        )\n        .episode(\n            Label::raw()\n                .with_line_break_mode(LineBreaking::Clip)\n                .with_font(theme::UI_FONT_MEDIUM)\n                .lens(Episode::name.in_arc()),\n        )\n        .lens(NowPlaying::item);\n\n    let detail = PlayableMatcher::new()\n        .track(\n            Label::raw()\n                .with_line_break_mode(LineBreaking::Clip)\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .lens(Track::lens_artist_name().in_arc()),\n        )\n        .episode(\n            Label::raw()\n                .with_line_break_mode(LineBreaking::Clip)\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .lens(Episode::show.in_arc().then(ShowLink::name)),\n        )\n        .lens(NowPlaying::item);\n\n    let origin = ViewSwitcher::new(\n        |origin: &PlaybackOrigin, _| origin.clone(),\n        |origin, _, _| {\n            Flex::row()\n                .cross_axis_alignment(CrossAxisAlignment::Center)\n                .with_flex_child(\n                    Label::dynamic(|origin: &PlaybackOrigin, _| origin.to_string())\n                        .with_line_break_mode(LineBreaking::Clip)\n                        .with_text_size(theme::TEXT_SIZE_SMALL),\n                    1.0,\n                )\n                .with_spacer(theme::grid(0.25))\n                .with_child(playback_origin_icon(origin).scale(theme::ICON_SIZE_SMALL))\n                .boxed()\n        },\n    )\n    .lens(NowPlaying::origin);\n\n    Flex::row()\n        .with_child(cover_art)\n        .with_flex_child(\n            Flex::row().with_spacer(theme::grid(2.0)).with_flex_child(\n                Flex::column()\n                    .cross_axis_alignment(CrossAxisAlignment::Start)\n                    .with_child(name)\n                    .with_spacer(2.0)\n                    .with_child(detail)\n                    .with_spacer(2.0)\n                    .with_child(origin)\n                    .on_click(|ctx, now_playing, _| {\n                        ctx.submit_command(cmd::NAVIGATE.with(now_playing.origin.to_nav()));\n                    })\n                    .context_menu(|now_playing| match &now_playing.item {\n                        Playable::Track(track) => track::track_menu(\n                            track,\n                            &now_playing.library,\n                            &now_playing.origin,\n                            usize::MAX,\n                        ),\n                        Playable::Episode(episode) => {\n                            episode::episode_menu(episode, &now_playing.library)\n                        }\n                    }),\n                1.0,\n            ),\n            1.0,\n        )\n        .with_child(ViewSwitcher::new(\n            |now_playing: &NowPlaying, _| {\n                now_playing.item.track().is_some() && now_playing.library.saved_tracks.is_resolved()\n            },\n            |selector, _data, _env| match selector {\n                true => {\n                    // View is only show if now_playing's track isn't none\n                    ViewSwitcher::new(\n                        |now_playing: &NowPlaying, _| {\n                            now_playing\n                                .library\n                                .contains_track(now_playing.item.track().unwrap())\n                        },\n                        |selector: &bool, _, _| {\n                            match selector {\n                                true => &icons::CIRCLE_CHECK,\n                                false => &icons::CIRCLE_PLUS,\n                            }\n                            .scale(theme::ICON_SIZE_SMALL)\n                            .boxed()\n                        },\n                    )\n                    .on_left_click(|ctx, _, now_playing, _| {\n                        let track = now_playing.item.track().unwrap();\n                        if now_playing.library.contains_track(track) {\n                            ctx.submit_command(library::UNSAVE_TRACK.with(track.id))\n                        } else {\n                            ctx.submit_command(library::SAVE_TRACK.with(track.clone()))\n                        }\n                    })\n                    .padding(theme::grid(1.0))\n                    .boxed()\n                }\n                false => Box::new(Flex::column()),\n            },\n        ))\n        .padding(theme::grid(1.0))\n        .link()\n}\n\nfn cover_widget(size: f64) -> impl Widget<NowPlaying> {\n    RemoteImage::new(utils::placeholder_widget(), move |np: &NowPlaying, _| {\n        np.cover_image_url(size, size).map(|url| url.into())\n    })\n    .fix_size(size, size)\n    .clip(Size::new(size, size).to_rounded_rect(4.0))\n    .on_left_click(|ctx, _, _, _| {\n        ctx.submit_command(SHOW_ARTWORK);\n    })\n}\n\nfn playback_origin_icon(origin: &PlaybackOrigin) -> &'static SvgIcon {\n    match origin {\n        PlaybackOrigin::Home => &icons::HOME,\n        PlaybackOrigin::Library => &icons::HEART,\n        PlaybackOrigin::Album { .. } => &icons::ALBUM,\n        PlaybackOrigin::Artist { .. } => &icons::ARTIST,\n        PlaybackOrigin::Playlist { .. } => &icons::PLAYLIST,\n        PlaybackOrigin::Show { .. } => &icons::PODCAST,\n        PlaybackOrigin::Search { .. } => &icons::SEARCH,\n        PlaybackOrigin::Recommendations { .. } => &icons::SEARCH,\n    }\n}\n\nfn player_widget() -> impl Widget<Playback> {\n    Flex::row()\n        .with_child(\n            small_button_widget(&icons::SKIP_BACK).on_left_click(|ctx, _, _, _| {\n                ctx.submit_command(cmd::PLAY_PREVIOUS);\n            }),\n        )\n        .with_default_spacer()\n        .with_child(player_play_pause_widget())\n        .with_default_spacer()\n        .with_child(\n            small_button_widget(&icons::SKIP_FORWARD).on_left_click(|ctx, _, _, _| {\n                ctx.submit_command(cmd::PLAY_NEXT);\n            }),\n        )\n        .with_default_spacer()\n        .with_child(queue_behavior_widget())\n        .with_default_spacer()\n        .with_child(Maybe::or_empty(durations_widget).lens(Playback::now_playing))\n        .with_child(\n            small_button_widget(&icons::MUSIC_NOTE)\n                .align_right()\n                .on_left_click(|ctx, _, _, _| {\n                    ctx.submit_command(TOGGLE_LYRICS);\n                }),\n        )\n        .padding(theme::grid(2.0))\n}\n\nfn player_play_pause_widget() -> impl Widget<Playback> {\n    ViewSwitcher::new(\n        |playback: &Playback, _| playback.state,\n        |state, _, _| match state {\n            PlaybackState::Loading => Spinner::new()\n                .with_color(theme::GREY_400)\n                .fix_size(theme::grid(3.0), theme::grid(3.0))\n                .padding(theme::grid(1.0))\n                .link()\n                .circle()\n                .border(theme::GREY_600, 1.0)\n                .on_left_click(|ctx, _, _, _| ctx.submit_command(cmd::PLAY_STOP))\n                .boxed(),\n            PlaybackState::Playing => icons::PAUSE\n                .scale((theme::grid(3.0), theme::grid(3.0)))\n                .padding(theme::grid(1.0))\n                .link()\n                .circle()\n                .border(theme::GREY_500, 1.0)\n                .on_left_click(|ctx, _, _, _| ctx.submit_command(cmd::PLAY_PAUSE))\n                .boxed(),\n            PlaybackState::Paused => icons::PLAY\n                .scale((theme::grid(3.0), theme::grid(3.0)))\n                .padding(theme::grid(1.0))\n                .link()\n                .circle()\n                .border(theme::GREY_500, 1.0)\n                .on_left_click(|ctx, _, _, _| ctx.submit_command(cmd::PLAY_RESUME))\n                .boxed(),\n            PlaybackState::Stopped => Empty.boxed(),\n        },\n    )\n}\n\nfn queue_behavior_widget() -> impl Widget<Playback> {\n    ViewSwitcher::new(\n        |playback: &Playback, _| playback.queue_behavior,\n        |behavior, _, _| {\n            faded_button_widget(queue_behavior_icon(behavior))\n                .on_left_click(|ctx, _, playback: &mut Playback, _| {\n                    ctx.submit_command(\n                        cmd::PLAY_QUEUE_BEHAVIOR\n                            .with(cycle_queue_behavior(&playback.queue_behavior)),\n                    );\n                })\n                .boxed()\n        },\n    )\n}\n\nfn cycle_queue_behavior(qb: &QueueBehavior) -> QueueBehavior {\n    match qb {\n        QueueBehavior::Sequential => QueueBehavior::Random,\n        QueueBehavior::Random => QueueBehavior::LoopTrack,\n        QueueBehavior::LoopTrack => QueueBehavior::LoopAll,\n        QueueBehavior::LoopAll => QueueBehavior::Sequential,\n    }\n}\n\nfn queue_behavior_icon(qb: &QueueBehavior) -> &'static SvgIcon {\n    match qb {\n        QueueBehavior::Sequential => &icons::PLAY_SEQUENTIAL,\n        QueueBehavior::Random => &icons::PLAY_SHUFFLE,\n        QueueBehavior::LoopTrack => &icons::PLAY_LOOP_TRACK,\n        QueueBehavior::LoopAll => &icons::PLAY_LOOP_ALL,\n    }\n}\n\nfn small_button_widget<T: Data>(svg: &SvgIcon) -> impl Widget<T> {\n    svg.scale((theme::grid(2.0), theme::grid(2.0)))\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n}\n\nfn faded_button_widget<T: Data>(svg: &SvgIcon) -> impl Widget<T> {\n    svg.scale((theme::grid(2.0), theme::grid(2.0)))\n        .with_color(theme::PLACEHOLDER_COLOR)\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n}\n\nfn durations_widget() -> impl Widget<NowPlaying> {\n    Label::dynamic(|now_playing: &NowPlaying, _| {\n        format!(\n            \"{} / {}\",\n            utils::as_minutes_and_seconds(now_playing.progress),\n            utils::as_minutes_and_seconds(now_playing.item.duration())\n        )\n    })\n    .with_text_size(theme::TEXT_SIZE_SMALL)\n    .with_text_color(theme::PLACEHOLDER_COLOR)\n    .fix_width(theme::grid(8.0))\n}\n\nstruct BarLayout<T, I, P> {\n    item: WidgetPod<T, I>,\n    player: WidgetPod<T, P>,\n}\n\nimpl<T, I, P> BarLayout<T, I, P>\nwhere\n    T: Data,\n    I: Widget<T>,\n    P: Widget<T>,\n{\n    fn new(item: I, player: P) -> Self {\n        Self {\n            item: WidgetPod::new(item),\n            player: WidgetPod::new(player),\n        }\n    }\n}\n\nimpl<T, I, P> Widget<T> for BarLayout<T, I, P>\nwhere\n    T: Data,\n    I: Widget<T>,\n    P: Widget<T>,\n{\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.item.event(ctx, event, data, env);\n        self.player.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.item.lifecycle(ctx, event, data, env);\n        self.player.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        self.item.update(ctx, data, env);\n        self.player.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let max = bc.max();\n\n        const PLAYER_OPTICAL_CENTER: f64 = 60.0 + theme::GRID * 2.0;\n\n        // Layout the player with loose constraints.\n        let player = self.player.layout(ctx, &bc.loosen(), data, env);\n        let player_centered = max.width > player.width * 2.25;\n\n        // Layout the item to the available space.\n        let item_max = if player_centered {\n            Size::new(max.width * 0.5 - PLAYER_OPTICAL_CENTER, max.height)\n        } else {\n            Size::new(max.width - player.width, max.height)\n        };\n        let item = self\n            .item\n            .layout(ctx, &BoxConstraints::new(Size::ZERO, item_max), data, env);\n\n        let total = Size::new(max.width, player.height.max(item.height));\n\n        // Put the item to the top left.\n        self.item.set_origin(ctx, Point::ORIGIN);\n\n        // Put the player either to the center or to the right.\n        let player_pos = if player_centered {\n            Point::new(\n                total.width * 0.5 - PLAYER_OPTICAL_CENTER,\n                total.height * 0.5 - player.height * 0.5,\n            )\n        } else {\n            Point::new(\n                total.width - player.width,\n                total.height * 0.5 - player.height * 0.5,\n            )\n        };\n        self.player.set_origin(ctx, player_pos);\n\n        total\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        self.item.paint(ctx, data, env);\n        self.player.paint(ctx, data, env);\n    }\n}\n\nstruct SeekBar {\n    loudness_path: BezPath,\n}\n\nimpl SeekBar {\n    fn new() -> Self {\n        Self {\n            loudness_path: BezPath::new(),\n        }\n    }\n}\n\nimpl Widget<NowPlaying> for SeekBar {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, _data: &mut NowPlaying, _env: &Env) {\n        match event {\n            Event::MouseMove(_) => {\n                ctx.set_cursor(&Cursor::Pointer);\n            }\n            Event::MouseDown(mouse) => {\n                if mouse.button == MouseButton::Left {\n                    ctx.set_active(true);\n                }\n            }\n            Event::MouseUp(mouse) => {\n                if ctx.is_active() && mouse.button == MouseButton::Left {\n                    if ctx.is_hot() {\n                        let fraction = mouse.pos.x / ctx.size().width;\n                        ctx.submit_command(cmd::PLAY_SEEK.with(fraction));\n                    }\n                    ctx.set_active(false);\n                }\n            }\n            _ => {}\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        _data: &NowPlaying,\n        _env: &Env,\n    ) {\n        match &event {\n            LifeCycle::Size(_bounds) => {\n                // self.loudness_path = compute_loudness_path(bounds, &data);\n            }\n            LifeCycle::HotChanged(_) => {\n                ctx.request_paint();\n            }\n            _ => {}\n        }\n    }\n\n    fn update(\n        &mut self,\n        ctx: &mut UpdateCtx,\n        old_data: &NowPlaying,\n        data: &NowPlaying,\n        _env: &Env,\n    ) {\n        if !old_data.same(data) {\n            ctx.request_paint();\n        }\n    }\n\n    fn layout(\n        &mut self,\n        _ctx: &mut LayoutCtx,\n        bc: &BoxConstraints,\n        _data: &NowPlaying,\n        _env: &Env,\n    ) -> Size {\n        Size::new(bc.max().width, theme::grid(1.0))\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &NowPlaying, env: &Env) {\n        if self.loudness_path.is_empty() {\n            paint_progress_bar(ctx, data, env)\n        } else {\n            paint_audio_analysis(ctx, data, &self.loudness_path, env)\n        }\n    }\n}\n\nfn _compute_loudness_path_from_analysis(\n    bounds: &Size,\n    total_duration: &Duration,\n    analysis: &AudioAnalysis,\n) -> BezPath {\n    let (loudness_min, loudness_max) = analysis\n        .segments\n        .iter()\n        .map(|s| s.loudness_max)\n        .minmax()\n        .into_option()\n        .unwrap_or((0.0, 0.0));\n    let total_loudness = loudness_max - loudness_min;\n\n    let mut path = BezPath::new();\n\n    // We start in the middle of the vertical space and first draw the upper half of\n    // the curve, then take what we have drawn, flip the y-axis and append it\n    // underneath.\n    let origin_y = bounds.height / 2.0;\n\n    // Start at the origin.\n    path.move_to((0.0, origin_y));\n\n    // Because the size of the seekbar is quite small, but the number of the\n    // segments can be large, we down-sample the loudness spectrum in a very\n    // primitive way and only add a vertex after crossing `WIDTH_PRECISION` of\n    // pixels horizontally.\n    const WIDTH_PRECISION: f64 = 2.0;\n    let mut last_width = 0.0;\n\n    for seg in &analysis.segments {\n        let time = seg.interval.start.as_secs_f64() + seg.loudness_max_time;\n        let tfrac = time / total_duration.as_secs_f64();\n        let width = bounds.width * tfrac;\n\n        let loud = seg.loudness_max - loudness_min;\n        let lfrac = loud / total_loudness;\n        let height = bounds.height * lfrac;\n\n        if width - last_width >= WIDTH_PRECISION {\n            // Down-scale the height, because we will be drawing also the inverted half.\n            path.line_to((width, origin_y - height / 2.0));\n\n            // Save the X-coordinate of this vertex.\n            last_width = width;\n        }\n    }\n\n    // Land back at the vertical origin.\n    path.line_to((bounds.width, origin_y));\n\n    // Flip the y-axis, translate just under the origin, and append.\n    let mut inverted_path = path.clone();\n    let inversion_tx = Affine::FLIP_Y * Affine::translate((0.0, -bounds.height));\n    inverted_path.apply_affine(inversion_tx);\n    path.extend(inverted_path);\n\n    path\n}\n\nfn paint_audio_analysis(ctx: &mut PaintCtx, data: &NowPlaying, path: &BezPath, env: &Env) {\n    let bounds = ctx.size();\n\n    let elapsed_time = data.progress.as_secs_f64();\n    let total_time = data.item.duration().as_secs_f64();\n    let elapsed_frac = elapsed_time / total_time;\n    let elapsed_width = bounds.width * elapsed_frac;\n    let elapsed = Size::new(elapsed_width, bounds.height).to_rect();\n\n    let (elapsed_color, remaining_color) = if ctx.is_hot() {\n        (env.get(theme::GREY_200), env.get(theme::GREY_500))\n    } else {\n        (env.get(theme::GREY_300), env.get(theme::GREY_600))\n    };\n\n    ctx.with_save(|ctx| {\n        ctx.fill(path, &remaining_color);\n        ctx.clip(elapsed);\n        ctx.fill(path, &elapsed_color);\n    });\n}\n\nfn paint_progress_bar(ctx: &mut PaintCtx, data: &NowPlaying, env: &Env) {\n    let elapsed_time = data.progress.as_secs_f64();\n    let total_time = data.item.duration().as_secs_f64();\n\n    let (elapsed_color, remaining_color) = if ctx.is_hot() {\n        (env.get(theme::GREY_200), env.get(theme::GREY_500))\n    } else {\n        (env.get(theme::GREY_300), env.get(theme::GREY_600))\n    };\n    let bounds = ctx.size();\n\n    let elapsed_frac = elapsed_time / total_time;\n    let elapsed_width = bounds.width * elapsed_frac;\n    let remaining_width = bounds.width - elapsed_width;\n    let elapsed = Size::new(elapsed_width, bounds.height).round();\n    let remaining = Size::new(remaining_width, bounds.height).round();\n\n    ctx.fill(\n        Rect::from_origin_size(Point::ORIGIN, elapsed),\n        &elapsed_color,\n    );\n    ctx.fill(\n        Rect::from_origin_size(Point::new(elapsed.width, 0.0), remaining),\n        &remaining_color,\n    );\n}\n"
  },
  {
    "path": "psst-gui/src/ui/playlist.rs",
    "content": "use std::{cell::RefCell, cmp::Ordering, rc::Rc, sync::Arc};\n\nuse druid::{\n    im::Vector,\n    widget::{Button, Either, Flex, Label, LensWrap, LineBreaking, List, TextBox},\n    Insets, Lens, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget,\n    WidgetExt, WindowDesc,\n};\nuse itertools::Itertools;\n\nuse crate::{\n    cmd,\n    data::{\n        config::{SortCriteria, SortOrder},\n        AppState, Ctx, Library, Nav, Playlist, PlaylistAddTrack, PlaylistDetail, PlaylistLink,\n        PlaylistRemoveTrack, PlaylistTracks, Track, WithCtx,\n    },\n    error::Error,\n    ui::menu,\n    webapi::WebApi,\n    widget::{Async, Empty, MyWidgetExt, RemoteImage, ThemeScope},\n};\n\nuse super::{playable, theme, track, utils};\n\npub const LOAD_LIST: Selector = Selector::new(\"app.playlist.load-list\");\npub const LOAD_DETAIL: Selector<(PlaylistLink, AppState)> =\n    Selector::new(\"app.playlist.load-detail\");\npub const ADD_TRACK: Selector<PlaylistAddTrack> = Selector::new(\"app.playlist.add-track\");\npub const REMOVE_TRACK: Selector<PlaylistRemoveTrack> = Selector::new(\"app.playlist.remove-track\");\n\npub const FOLLOW_PLAYLIST: Selector<Playlist> = Selector::new(\"app.playlist.follow\");\npub const UNFOLLOW_PLAYLIST: Selector<PlaylistLink> = Selector::new(\"app.playlist.unfollow\");\npub const UNFOLLOW_PLAYLIST_CONFIRM: Selector<PlaylistLink> =\n    Selector::new(\"app.playlist.unfollow-confirm\");\n\npub const RENAME_PLAYLIST: Selector<PlaylistLink> = Selector::new(\"app.playlist.rename\");\npub const RENAME_PLAYLIST_CONFIRM: Selector<PlaylistLink> =\n    Selector::new(\"app.playlist.rename-confirm\");\n\nconst SHOW_RENAME_PLAYLIST_CONFIRM: Selector<PlaylistLink> =\n    Selector::new(\"app.playlist.show-rename\");\nconst SHOW_UNFOLLOW_PLAYLIST_CONFIRM: Selector<UnfollowPlaylist> =\n    Selector::new(\"app.playlist.show-unfollow-confirm\");\n\npub fn list_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        || {\n            List::new(|| {\n                Label::raw()\n                    .with_line_break_mode(LineBreaking::WordWrap)\n                    .with_text_size(theme::TEXT_SIZE_SMALL)\n                    .lens(Ctx::data().then(Playlist::name))\n                    .expand_width()\n                    .padding(Insets::uniform_xy(theme::grid(2.0), theme::grid(0.6)))\n                    .link()\n                    .on_left_click(|ctx, _, playlist, _| {\n                        ctx.submit_command(\n                            cmd::NAVIGATE.with(Nav::PlaylistDetail(playlist.data.link())),\n                        );\n                    })\n                    .context_menu(playlist_menu_ctx)\n            })\n        },\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::library.then(Library::playlists.in_arc()),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_LIST,\n        |_| WebApi::global().get_playlists(),\n        |_, data, d| data.with_library_mut(|l| l.playlists.defer(d)),\n        |_, data, r| data.with_library_mut(|l| l.playlists.update(r)),\n    )\n    .on_command_async(\n        ADD_TRACK,\n        |d| {\n            WebApi::global().add_track_to_playlist(\n                &d.link.id,\n                &d.track_id\n                    .0\n                    .to_uri()\n                    .ok_or_else(|| Error::WebApiError(\"Item doesn't have URI\".to_string()))?,\n            )\n        },\n        |_, data, d| {\n            data.with_library_mut(|library| library.increment_playlist_track_count(&d.link))\n        },\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Added to playlist.\");\n            }\n        },\n    )\n    .on_command_async(\n        UNFOLLOW_PLAYLIST,\n        |link| WebApi::global().unfollow_playlist(link.id.as_ref()),\n        |_, data: &mut AppState, d| data.with_library_mut(|l| l.remove_from_playlist(&d.id)),\n        |_, data, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Playlist removed from library.\");\n            }\n        },\n    )\n    .on_command_async(\n        FOLLOW_PLAYLIST,\n        |link| WebApi::global().follow_playlist(link.id.as_ref()),\n        |_, data: &mut AppState, d| data.with_library_mut(|l| l.add_playlist(d)),\n        |_, data: &mut AppState, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Playlist added to library.\")\n            }\n        },\n    )\n    .on_command_async(\n        RENAME_PLAYLIST,\n        |link| WebApi::global().change_playlist_details(link.id.as_ref(), link.name.as_ref()),\n        |_, data: &mut AppState, link| data.with_library_mut(|l| l.rename_playlist(link)),\n        |_, data: &mut AppState, (_, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Playlist renamed.\")\n            }\n        },\n    )\n    .on_command(SHOW_UNFOLLOW_PLAYLIST_CONFIRM, |ctx, msg, _| {\n        let window = unfollow_confirm_window(msg.clone());\n        ctx.new_window(window);\n    })\n    .on_command(SHOW_RENAME_PLAYLIST_CONFIRM, |ctx, link, _| {\n        let window = rename_playlist_window(link.clone());\n        ctx.new_window(window);\n    })\n    .on_command_async(\n        REMOVE_TRACK,\n        |d| WebApi::global().remove_track_from_playlist(&d.link.id, d.track_pos),\n        |_, data, d| {\n            data.with_library_mut(|library| library.decrement_playlist_track_count(&d.link))\n        },\n        |e, data, (p, r)| {\n            if let Err(err) = r {\n                data.error_alert(err);\n            } else {\n                data.info_alert(\"Removed from playlist.\");\n            }\n            // Re-submit the `LOAD_DETAIL` command to reload the playlist data.\n            e.submit_command(LOAD_DETAIL.with((p.link, data.clone())))\n        },\n    )\n}\n\nfn unfollow_confirm_window(msg: UnfollowPlaylist) -> WindowDesc<AppState> {\n    let win = WindowDesc::new(unfollow_playlist_confirm_widget(msg))\n        .window_size((theme::grid(45.0), theme::grid(25.0)))\n        .title(\"Unfollow playlist\")\n        .resizable(false)\n        .show_title(false)\n        .transparent_titlebar(true);\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\nfn unfollow_playlist_confirm_widget(msg: UnfollowPlaylist) -> impl Widget<AppState> {\n    let link = msg.link;\n\n    let information_section = if msg.created_by_user {\n        information_section(\n            format!(\"Delete {} from Library?\", link.name).as_str(),\n            \"This will delete the playlist from Your Library\",\n        )\n    } else {\n        information_section(\n            format!(\"Remove {} from Library?\", link.name).as_str(),\n            \"We'll remove this playlist from Your Library, but you'll still be able to search for it on Spotify\",\n        )\n    };\n\n    let button_section = button_section(\n        \"Delete\",\n        UNFOLLOW_PLAYLIST_CONFIRM,\n        Box::new(move || link.clone()),\n    );\n\n    ThemeScope::new(\n        Flex::column()\n            .with_child(information_section)\n            .with_flex_spacer(2.0)\n            .with_child(button_section)\n            .with_flex_spacer(2.0)\n            .background(theme::BACKGROUND_DARK),\n    )\n}\n\nfn rename_playlist_window(link: PlaylistLink) -> WindowDesc<AppState> {\n    let win = WindowDesc::new(rename_playlist_widget(link))\n        .window_size((theme::grid(45.0), theme::grid(30.0)))\n        .title(\"Rename playlist\")\n        .resizable(false)\n        .show_title(false)\n        .transparent_titlebar(true);\n    if cfg!(target_os = \"macos\") {\n        win.menu(menu::main_menu)\n    } else {\n        win\n    }\n}\n\n#[derive(Clone, Lens)]\nstruct TextInput {\n    input: Rc<RefCell<String>>,\n}\n\nimpl Lens<AppState, String> for TextInput {\n    fn with<V, F: FnOnce(&String) -> V>(&self, _data: &AppState, f: F) -> V {\n        f(&self.input.borrow())\n    }\n\n    fn with_mut<V, F: FnOnce(&mut String) -> V>(&self, _data: &mut AppState, f: F) -> V {\n        f(&mut self.input.borrow_mut())\n    }\n}\n\nfn rename_playlist_widget(link: PlaylistLink) -> impl Widget<AppState> {\n    let text_input = TextInput {\n        input: Rc::new(RefCell::new(link.name.to_string())),\n    };\n\n    let information_section = information_section(\n        \"Rename playlist?\",\n        \"Please enter a new name for your playlist\",\n    );\n    let input_section = LensWrap::new(\n        TextBox::new()\n            .padding_horizontal(theme::grid(2.0))\n            .expand_width(),\n        text_input.clone(),\n    );\n    let button_section = button_section(\n        \"Rename\",\n        RENAME_PLAYLIST_CONFIRM,\n        Box::new(move || PlaylistLink {\n            id: link.id.clone(),\n            name: Arc::from(text_input.input.borrow().clone().into_boxed_str()),\n        }),\n    );\n\n    ThemeScope::new(\n        Flex::column()\n            .with_child(information_section)\n            .with_child(input_section)\n            .with_flex_spacer(2.0)\n            .with_child(button_section)\n            .with_flex_spacer(2.0)\n            .background(theme::BACKGROUND_DARK),\n    )\n}\n\nfn button_section(\n    action_button_name: &str,\n    selector: Selector<PlaylistLink>,\n    link_extractor: Box<dyn Fn() -> PlaylistLink>,\n) -> impl Widget<AppState> {\n    let action_button = Button::new(action_button_name)\n        .fix_height(theme::grid(5.0))\n        .fix_width(theme::grid(9.0))\n        .on_click(move |ctx, _, _| {\n            ctx.submit_command(selector.with(link_extractor()));\n            ctx.window().close();\n        });\n    let cancel_button = Button::new(\"Cancel\")\n        .fix_height(theme::grid(5.0))\n        .fix_width(theme::grid(8.0))\n        .padding_left(theme::grid(3.0))\n        .padding_right(theme::grid(2.0))\n        .on_click(|ctx, _, _| ctx.window().close());\n\n    Flex::row()\n        .with_child(action_button)\n        .with_child(cancel_button)\n        .align_right()\n}\n\nfn information_section(title_msg: &str, description_msg: &str) -> impl Widget<AppState> {\n    let title_label = Label::new(title_msg)\n        .with_text_size(theme::TEXT_SIZE_LARGE)\n        .align_left()\n        .padding(theme::grid(2.0));\n\n    let description_label = Label::new(description_msg)\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .with_text_size(theme::TEXT_SIZE_NORMAL)\n        .align_left()\n        .padding(theme::grid(2.0));\n\n    Flex::column()\n        .with_child(title_label)\n        .with_child(description_label)\n}\n\npub fn playlist_widget(horizontal: bool) -> impl Widget<WithCtx<Playlist>> {\n    let playlist_image_size = if horizontal {\n        theme::grid(16.0)\n    } else {\n        theme::grid(6.0)\n    };\n    let playlist_image = rounded_cover_widget(playlist_image_size).lens(Ctx::data());\n\n    let playlist_name = Label::raw()\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_line_break_mode(LineBreaking::Clip)\n        .lens(Ctx::data().then(Playlist::name));\n\n    let playlist_description = Label::raw()\n        .with_line_break_mode(LineBreaking::WordWrap)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .lens(Ctx::data().then(Playlist::description));\n\n    let (playlist_name, playlist_description) = if horizontal {\n        (\n            playlist_name.fix_width(playlist_image_size).align_left(),\n            playlist_description\n                .fix_width(playlist_image_size)\n                .align_left(),\n        )\n    } else {\n        (\n            playlist_name.align_left(),\n            playlist_description.align_left(),\n        )\n    };\n\n    let playlist = if horizontal {\n        Flex::column()\n            .with_child(playlist_image)\n            .with_default_spacer()\n            .with_child(\n                Flex::column()\n                    .with_child(playlist_name)\n                    .with_spacer(2.0)\n                    .with_child(playlist_description)\n                    .align_horizontal(UnitPoint::CENTER)\n                    .align_vertical(UnitPoint::TOP)\n                    .fix_size(theme::grid(16.0), theme::grid(8.0)),\n            )\n            .padding(theme::grid(1.0))\n    } else {\n        Flex::row()\n            .with_child(playlist_image)\n            .with_default_spacer()\n            .with_flex_child(\n                Flex::column()\n                    .with_child(playlist_name)\n                    .with_spacer(2.0)\n                    .with_child(playlist_description),\n                1.0,\n            )\n            .padding(theme::grid(1.0))\n    };\n\n    playlist\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, playlist, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::PlaylistDetail(playlist.data.link())));\n        })\n        .context_menu(playlist_menu_ctx)\n}\n\nfn cover_widget(size: f64) -> impl Widget<Playlist> {\n    RemoteImage::new(\n        utils::placeholder_widget(),\n        move |playlist: &Playlist, _| playlist.image(size, size).map(|image| image.url.clone()),\n    )\n    .fix_size(size, size)\n}\n\nfn rounded_cover_widget(size: f64) -> impl Widget<Playlist> {\n    // TODO: Take the radius from theme.\n    cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0))\n}\n\npub fn detail_widget() -> impl Widget<AppState> {\n    use druid::widget::CrossAxisAlignment;\n\n    let playlist_top = async_playlist_info_widget().padding(theme::grid(1.0));\n\n    let playlist_tracks = async_tracks_widget();\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_spacer(theme::grid(1.0))\n        .with_child(playlist_top)\n        .with_spacer(theme::grid(1.0))\n        .with_child(playlist_tracks)\n}\n\nfn async_playlist_info_widget() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, playlist_info_widget, || Empty)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::playlist_detail.then(PlaylistDetail::playlist),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_DETAIL,\n            |d| WebApi::global().get_playlist(&d.0.id),\n            |_, data, d| data.playlist_detail.playlist.defer(d.0),\n            |_, data, (d, r)| data.playlist_detail.playlist.update((d.0, r)),\n        )\n}\n\nfn playlist_info_widget() -> impl Widget<WithCtx<Playlist>> {\n    use druid::widget::CrossAxisAlignment;\n\n    let size = theme::grid(10.0);\n    let playlist_cover = cover_widget(size)\n        .lens(Ctx::data())\n        .clip(Size::new(size, size).to_rounded_rect(4.0))\n        .context_menu(playlist_menu_ctx);\n\n    let owner_label = Label::dynamic(|p: &Playlist, _| p.owner.display_name.as_ref().to_string());\n\n    let track_count_label = Label::dynamic(|p: &Playlist, _| {\n        let count = p.track_count.unwrap_or(0);\n        if count == 1 {\n            \"1 song\".to_string()\n        } else {\n            format!(\"{count} songs\")\n        }\n    })\n    .with_text_size(theme::TEXT_SIZE_SMALL);\n\n    let description_widget = Either::new(\n        |p: &Playlist, _| !p.description.is_empty(),\n        Flex::column().with_default_spacer().with_child(\n            Label::dynamic(|p: &Playlist, _| p.description.to_string())\n                .with_line_break_mode(LineBreaking::Clip)\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .with_text_color(theme::PLACEHOLDER_COLOR),\n        ),\n        Empty,\n    );\n\n    let visibility_widget = Either::new(\n        |p: &Playlist, _| p.public.is_some() || p.collaborative,\n        Flex::column().with_default_spacer().with_child(\n            Label::dynamic(|p: &Playlist, _| {\n                let mut parts = Vec::new();\n                match p.public {\n                    Some(true) => parts.push(\"Public\"),\n                    Some(false) => parts.push(\"Private\"),\n                    None => {}\n                }\n                if p.collaborative {\n                    parts.push(\"Collaborative\");\n                }\n                parts.join(\" • \")\n            })\n            .with_line_break_mode(LineBreaking::WordWrap)\n            .with_text_size(theme::TEXT_SIZE_SMALL)\n            .with_text_color(theme::PLACEHOLDER_COLOR),\n        ),\n        Empty,\n    );\n\n    let playlist_info = Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(owner_label)\n        .with_default_spacer()\n        .with_child(track_count_label)\n        .with_child(description_widget)\n        .with_child(visibility_widget);\n\n    Flex::row()\n        .with_child(playlist_cover)\n        .with_default_spacer()\n        .with_flex_child(playlist_info.lens(Ctx::data()), 1.0)\n}\n\nfn async_tracks_widget() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, tracks_widget, utils::error_widget)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::playlist_detail.then(PlaylistDetail::tracks),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_DETAIL,\n            |arg: (PlaylistLink, AppState)| {\n                let d = arg.0;\n                let data = arg.1;\n                sort_playlist(&data, WebApi::global().get_playlist_tracks(&d.id))\n            },\n            |_, data, d| data.playlist_detail.tracks.defer(d.0),\n            |_, data, (d, r)| {\n                let tracks = PlaylistTracks {\n                    id: d.0.id.clone(),\n                    name: d.0.name.clone(),\n                    tracks: r,\n                };\n                data.playlist_detail.tracks.update((d.0, Ok(tracks)))\n            },\n        )\n}\n\nfn tracks_widget() -> impl Widget<WithCtx<PlaylistTracks>> {\n    playable::list_widget_with_find(\n        playable::Display {\n            track: track::Display {\n                title: true,\n                artist: true,\n                album: true,\n                cover: true,\n                ..track::Display::empty()\n            },\n        },\n        cmd::FIND_IN_PLAYLIST,\n    )\n}\n\nfn sort_playlist(data: &AppState, result: Result<Vector<Arc<Track>>, Error>) -> Vector<Arc<Track>> {\n    let sort_criteria = data.config.sort_criteria;\n    let sort_order = data.config.sort_order;\n\n    let playlist = result.unwrap_or_else(|_| Vector::new());\n\n    let sorted_playlist: Vector<Arc<Track>> = playlist\n        .into_iter()\n        .sorted_by(|a, b| {\n            let method = match sort_criteria {\n                SortCriteria::Title => a.name.cmp(&b.name),\n                SortCriteria::Artist => a.artist_name().cmp(&b.artist_name()),\n                SortCriteria::Album => a.album_name().cmp(&b.album_name()),\n                SortCriteria::Duration => a.duration.cmp(&b.duration),\n                SortCriteria::DateAdded => Ordering::Equal,\n            };\n\n            if sort_order == SortOrder::Descending {\n                method.reverse()\n            } else {\n                method\n            }\n        })\n        .collect();\n\n    if sort_criteria == SortCriteria::DateAdded && sort_order == SortOrder::Descending {\n        sorted_playlist.into_iter().rev().collect()\n    } else {\n        sorted_playlist\n    }\n}\n\nfn playlist_menu_ctx(playlist: &WithCtx<Playlist>) -> Menu<AppState> {\n    let library = &playlist.ctx.library;\n    let playlist = &playlist.data;\n\n    let mut menu = Menu::empty();\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Playlist\"),\n        )\n        .command(cmd::COPY.with(playlist.url())),\n    );\n\n    if library.contains_playlist(playlist) {\n        let created_by_user = library.is_created_by_user(playlist);\n\n        if created_by_user {\n            let unfollow_msg = UnfollowPlaylist {\n                link: playlist.link(),\n                created_by_user,\n            };\n            menu = menu.entry(\n                MenuItem::new(\n                    LocalizedString::new(\"menu-unfollow-playlist\")\n                        .with_placeholder(\"Delete playlist\"),\n                )\n                .command(SHOW_UNFOLLOW_PLAYLIST_CONFIRM.with(unfollow_msg)),\n            );\n            menu = menu.entry(\n                MenuItem::new(\n                    LocalizedString::new(\"menu-rename-playlist\")\n                        .with_placeholder(\"Rename playlist\"),\n                )\n                .command(SHOW_RENAME_PLAYLIST_CONFIRM.with(playlist.link())),\n            );\n        } else {\n            let unfollow_msg = UnfollowPlaylist {\n                link: playlist.link(),\n                created_by_user,\n            };\n            menu = menu.entry(\n                MenuItem::new(\n                    LocalizedString::new(\"menu-unfollow-playlist\")\n                        .with_placeholder(\"Remove playlist from Your Library\"),\n                )\n                .command(SHOW_UNFOLLOW_PLAYLIST_CONFIRM.with(unfollow_msg)),\n            );\n        }\n    } else {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-follow-playlist\").with_placeholder(\"Follow Playlist\"),\n            )\n            .command(FOLLOW_PLAYLIST.with(playlist.clone())),\n        );\n    }\n\n    menu\n}\n\n#[derive(Clone)]\nstruct UnfollowPlaylist {\n    link: PlaylistLink,\n    created_by_user: bool,\n}\n"
  },
  {
    "path": "psst-gui/src/ui/preferences.rs",
    "content": "use std::net::{IpAddr, Ipv4Addr, SocketAddr};\nuse std::thread::{self, JoinHandle};\nuse std::time::Duration;\n\nuse crate::{\n    cmd,\n    data::{\n        AppState, AudioQuality, Authentication, Config, Preferences, PreferencesTab, Promise,\n        SliderScrollScale, Theme,\n    },\n    widget::{icons, Async, Border, Checkbox, MyWidgetExt},\n};\nuse druid::{\n    text::ParseFormatter,\n    widget::{\n        Button, Controller, CrossAxisAlignment, Flex, Label, LineBreaking, MainAxisAlignment,\n        RadioGroup, SizedBox, Slider, TextBox, ViewSwitcher,\n    },\n    Color, Data, Env, Event, EventCtx, Insets, Lens, LensExt, LifeCycle, LifeCycleCtx, Selector,\n    Widget, WidgetExt,\n};\nuse psst_core::{connection::Credentials, lastfm, oauth, session::SessionConfig};\n\nuse super::{icons::SvgIcon, theme};\n\nconst CLEAR_CACHE: Selector = Selector::new(\"app.preferences.clear-cache\");\n\n// Helper function for creating a labeled input row\nfn make_input_row<L>(\n    label_text: &'static str,\n    placeholder_text: &'static str,\n    lens: L,\n) -> impl Widget<AppState>\nwhere\n    L: Lens<AppState, String> + 'static,\n{\n    Flex::row()\n        .cross_axis_alignment(CrossAxisAlignment::Center)\n        .with_child(\n            SizedBox::new(Label::new(label_text))\n                .width(theme::grid(12.0))\n                .align_left(),\n        )\n        .with_flex_child(\n            TextBox::new()\n                .with_placeholder(placeholder_text)\n                .lens(lens)\n                .fix_width(theme::grid(30.0)),\n            1.0,\n        )\n}\n\npub fn account_setup_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .must_fill_main_axis(true)\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Label::new(\"Please insert your Spotify Premium credentials.\")\n                .with_font(theme::UI_FONT_MEDIUM)\n                .with_line_break_mode(LineBreaking::WordWrap),\n        )\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Label::new(\n                \"Psst connects only to the official servers, and does not store your password.\",\n            )\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .with_line_break_mode(LineBreaking::WordWrap),\n        )\n        .with_spacer(theme::grid(6.0))\n        .with_child(account_tab_widget(AccountTab::FirstSetup).expand_width())\n        .padding(theme::grid(4.0))\n}\n\npub fn preferences_widget() -> impl Widget<AppState> {\n    const PROPAGATE_FLAGS: Selector = Selector::new(\"app.preferences.propagate-flags\");\n\n    Flex::column()\n        .must_fill_main_axis(true)\n        .cross_axis_alignment(CrossAxisAlignment::Fill)\n        .with_child(\n            tabs_widget()\n                .padding(theme::grid(2.0))\n                .background(theme::BACKGROUND_LIGHT),\n        )\n        .with_child(\n            ViewSwitcher::new(\n                |state: &AppState, _| state.preferences.active,\n                |active, _, _| match active {\n                    PreferencesTab::General => general_tab_widget().boxed(),\n                    PreferencesTab::Account => {\n                        account_tab_widget(AccountTab::InPreferences).boxed()\n                    }\n                    PreferencesTab::Cache => cache_tab_widget().boxed(),\n                    PreferencesTab::About => about_tab_widget().boxed(),\n                },\n            )\n            .padding(theme::grid(4.0))\n            .background(Border::Top.with_color(theme::GREY_500)),\n        )\n        .on_update(|ctx, old_data, data, _| {\n            // Immediately save any changes in the config.\n            if !old_data.config.same(&data.config) {\n                data.config.save();\n            }\n\n            // Propagate some flags further to the state.\n            if !old_data\n                .config\n                .show_track_cover\n                .same(&data.config.show_track_cover)\n            {\n                ctx.submit_command(PROPAGATE_FLAGS);\n            }\n        })\n        .on_command(PROPAGATE_FLAGS, |_, (), data| {\n            data.common_ctx_mut().show_track_cover = data.config.show_track_cover;\n        })\n        .scroll()\n        .vertical()\n        .content_must_fill(true)\n        .padding(if cfg!(target_os = \"macos\") {\n            // Accommodate the window controls on Mac.\n            Insets::new(0.0, 24.0, 0.0, 0.0)\n        } else {\n            Insets::ZERO\n        })\n}\n\nfn tabs_widget() -> impl Widget<AppState> {\n    Flex::row()\n        .must_fill_main_axis(true)\n        .main_axis_alignment(MainAxisAlignment::Center)\n        .with_child(tab_link_widget(\n            \"General\",\n            &icons::PREFERENCES,\n            PreferencesTab::General,\n        ))\n        .with_default_spacer()\n        .with_child(tab_link_widget(\n            \"Account\",\n            &icons::ACCOUNT,\n            PreferencesTab::Account,\n        ))\n        .with_default_spacer()\n        .with_child(tab_link_widget(\n            \"Cache\",\n            &icons::STORAGE,\n            PreferencesTab::Cache,\n        ))\n        .with_default_spacer()\n        .with_child(tab_link_widget(\n            \"About\",\n            &icons::HEART,\n            PreferencesTab::About,\n        ))\n}\n\nfn tab_link_widget(\n    text: &'static str,\n    icon: &SvgIcon,\n    tab: PreferencesTab,\n) -> impl Widget<AppState> {\n    Flex::column()\n        .with_child(icon.scale(theme::ICON_SIZE_LARGE))\n        .with_default_spacer()\n        .with_child(Label::new(text).with_font(theme::UI_FONT_MEDIUM))\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .active(move |state: &AppState, _| tab == state.preferences.active)\n        .on_left_click(move |_, _, state: &mut AppState, _| {\n            state.preferences.active = tab;\n        })\n        .env_scope(|env, _| {\n            env.set(theme::LINK_ACTIVE_COLOR, env.get(theme::BACKGROUND_DARK));\n        })\n}\n\nfn general_tab_widget() -> impl Widget<AppState> {\n    let mut col = Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .must_fill_main_axis(true);\n\n    // Theme\n    col = col\n        .with_child(Label::new(\"Theme\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            RadioGroup::column(vec![(\"Light\", Theme::Light), (\"Dark\", Theme::Dark)])\n                .lens(AppState::config.then(Config::theme)),\n        );\n\n    col = col.with_spacer(theme::grid(1.5));\n\n    // Show track covers\n    col = col.with_child(\n        Checkbox::new(\"Show album covers for tracks\")\n            .lens(AppState::config.then(Config::show_track_cover)),\n    );\n\n    col = col.with_spacer(theme::grid(3.0));\n\n    // Audio quality\n    col = col\n        .with_child(Label::new(\"Audio quality\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            RadioGroup::column(vec![\n                (\"Low (96kbit)\", AudioQuality::Low),\n                (\"Normal (160kbit)\", AudioQuality::Normal),\n                (\"High (320kbit)\", AudioQuality::High),\n            ])\n            .lens(AppState::config.then(Config::audio_quality)),\n        );\n\n    col = col.with_spacer(theme::grid(3.0));\n\n    // Sliders\n    col = col\n        .with_child(Label::new(\"Slider Scrolling\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Flex::row()\n                .with_child(\n                    SizedBox::new(Label::dynamic(|state: &AppState, _| {\n                        format!(\"{:.1}\", state.config.slider_scroll_scale.scale)\n                    }))\n                    .width(20.0),\n                )\n                .with_spacer(theme::grid(0.5))\n                .with_child(\n                    Slider::new().with_range(0.0, 7.0).lens(\n                        AppState::config\n                            .then(Config::slider_scroll_scale)\n                            .then(SliderScrollScale::scale),\n                    ),\n                )\n                .with_spacer(theme::grid(0.5))\n                .with_child(Label::new(\"Sensitivity\")),\n        );\n\n    col = col.with_spacer(theme::grid(3.0));\n\n    col = col\n        .with_child(Label::new(\"Seek Duration\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Flex::row()\n                .with_child(\n                    TextBox::new().with_formatter(ParseFormatter::with_format_fn(\n                        |usize: &usize| usize.to_string(),\n                    )),\n                )\n                .lens(AppState::config.then(Config::seek_duration)),\n        );\n\n    col = col.with_spacer(theme::grid(3.0));\n\n    col = col\n        .with_child(\n            Label::new(\"Max Loaded Tracks (requires restart)\").with_font(theme::UI_FONT_MEDIUM),\n        )\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Flex::row()\n                .with_child(\n                    TextBox::new().with_formatter(ParseFormatter::with_format_fn(\n                        |usize: &usize| usize.to_string(),\n                    )),\n                )\n                .lens(AppState::config.then(Config::paginated_limit)),\n        );\n\n    col\n}\n\nstruct CacheController {\n    thread: Option<JoinHandle<()>>,\n}\n\nimpl CacheController {\n    const RESULT: Selector<Option<u64>> = Selector::new(\"app.preferences.measure-cache-size\");\n\n    fn new() -> Self {\n        Self { thread: None }\n    }\n\n    fn start_measuring(&mut self, sink: druid::ExtEventSink, widget_id: druid::WidgetId) {\n        if self.thread.is_some() {\n            return;\n        }\n        let handle = thread::spawn(move || {\n            let size = Preferences::measure_cache_usage();\n            sink.submit_command(Self::RESULT, size, widget_id).unwrap();\n        });\n        self.thread.replace(handle);\n    }\n}\n\nimpl<W: Widget<Preferences>> Controller<Preferences, W> for CacheController {\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut Preferences,\n        env: &Env,\n    ) {\n        match &event {\n            Event::Command(cmd) if cmd.is(CLEAR_CACHE) => {\n                if let Some(cache) = &data.cache {\n                    if let Err(err) = cache.clear() {\n                        log::error!(\"Failed to clear cache: {err}\");\n                    } else {\n                        // After clearing, re-measure the cache size.\n                        self.start_measuring(ctx.get_external_handle(), ctx.widget_id());\n                    }\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(Self::RESULT) => {\n                let result = cmd.get_unchecked(Self::RESULT).to_owned();\n                data.cache_size.resolve_or_reject((), result.ok_or(()));\n                self.thread.take();\n                ctx.set_handled();\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &Preferences,\n        env: &Env,\n    ) {\n        if let LifeCycle::WidgetAdded = &event {\n            self.start_measuring(ctx.get_external_handle(), ctx.widget_id());\n        }\n        child.lifecycle(ctx, event, data, env);\n    }\n}\n\n#[derive(Copy, Clone)]\nenum AccountTab {\n    FirstSetup,\n    InPreferences,\n}\n\nfn account_tab_widget(tab: AccountTab) -> impl Widget<AppState> {\n    let mut col = Flex::column().cross_axis_alignment(match tab {\n        AccountTab::FirstSetup => CrossAxisAlignment::Center,\n        AccountTab::InPreferences => CrossAxisAlignment::Start,\n    });\n\n    if matches!(tab, AccountTab::InPreferences) {\n        col = col\n            .with_child(Label::new(\"Spotify Account\").with_font(theme::UI_FONT_MEDIUM))\n            .with_spacer(theme::grid(2.0));\n    }\n\n    // Spotify Login/Logout button\n    col = col\n        .with_child(ViewSwitcher::new(\n            |data: &AppState, _| data.config.has_credentials(),\n            |is_logged_in, _, _| {\n                if *is_logged_in {\n                    Button::new(\"Log Out\")\n                        .on_left_click(|ctx, _, _, _| {\n                            ctx.submit_command(cmd::LOG_OUT);\n                        })\n                        .boxed()\n                } else {\n                    Button::new(\"Log in with Spotify\")\n                        .on_click(|ctx, _data: &mut AppState, _| {\n                            ctx.submit_command(Authenticate::SPOTIFY_REQUEST);\n                        })\n                        .boxed()\n                }\n            },\n        ))\n        .with_spacer(theme::grid(1.0))\n        .with_child(\n            Async::new(\n                || Label::new(\"Logging in...\").with_text_size(theme::TEXT_SIZE_SMALL),\n                // Spotify Success Arm: Show nothing\n                || SizedBox::empty().boxed(),\n                || {\n                    // Error arm remains the same\n                    Label::dynamic(|err: &String, _| err.to_owned())\n                        .with_text_size(theme::TEXT_SIZE_SMALL)\n                        .with_text_color(druid::Color::RED)\n                },\n            )\n            .lens(\n                AppState::preferences\n                    .then(Preferences::auth)\n                    .then(Authentication::result),\n            ),\n        );\n\n    if matches!(tab, AccountTab::InPreferences) {\n        col = col\n            .with_spacer(theme::grid(2.0))\n            .with_child(Label::new(\"Last.fm Account\").with_font(theme::UI_FONT_MEDIUM))\n            .with_spacer(theme::grid(1.0))\n            .with_child(\n                Label::new(\"Connect your Last.fm account to scrobble tracks you listen to.\")\n                    .with_text_color(theme::PLACEHOLDER_COLOR)\n                    .with_line_break_mode(LineBreaking::WordWrap),\n            )\n            .with_spacer(theme::grid(2.0))\n            .with_child(ViewSwitcher::new(\n                |data: &AppState, _| data.config.lastfm_session_key.is_some(),\n                |connected, _, _| {\n                    if *connected {\n                        // --- Connected View ---\n                        lastfm_connected_view().boxed()\n                    } else {\n                        // --- Disconnected View ---\n                        lastfm_disconnected_view().boxed()\n                    }\n                },\n            ));\n    }\n    col.controller(Authenticate::new(tab))\n}\n\nfn lastfm_connected_view() -> impl Widget<AppState> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(\n            Flex::row()\n                .with_child(\n                    Checkbox::new(\"Toggle scrobbling\")\n                        .lens(AppState::config.then(Config::lastfm_enable))\n                        .padding((0.0, 0.0, theme::grid(1.0), 0.0)),\n                )\n                .with_child(\n                    Button::new(\"Disconnect\").on_click(|_ctx, data: &mut AppState, _| {\n                        data.config.lastfm_session_key = None;\n                        data.config.lastfm_api_key = None;\n                        data.config.lastfm_api_secret = None;\n                        data.config.save();\n                        data.preferences.lastfm_auth_result = None;\n                        data.preferences.auth.lastfm_api_key_input.clear();\n                        data.preferences.auth.lastfm_api_secret_input.clear();\n                    }),\n                ),\n        )\n}\n\nfn lastfm_disconnected_view() -> impl Widget<AppState> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(make_input_row(\n            \"API Key:\",\n            \"Enter your Last.fm API Key\",\n            AppState::preferences\n                .then(Preferences::auth)\n                .then(Authentication::lastfm_api_key_input),\n        ))\n        .with_default_spacer()\n        .with_child(make_input_row(\n            \"API Secret:\",\n            \"Enter your Last.fm API Secret\",\n            AppState::preferences\n                .then(Preferences::auth)\n                .then(Authentication::lastfm_api_secret_input),\n        ))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Flex::row() // Put buttons in a row\n                .with_child(Button::new(\"Connect Last.fm Account\").on_click(\n                    |ctx, data: &mut AppState, _| {\n                        // Check temporary input fields before proceeding\n                        let key_input = &data.preferences.auth.lastfm_api_key_input;\n                        let secret_input = &data.preferences.auth.lastfm_api_secret_input;\n\n                        if key_input.is_empty() || secret_input.is_empty() {\n                            data.preferences.lastfm_auth_result =\n                                Some(\"API Key and Secret required.\".to_string());\n                        } else {\n                            ctx.submit_command(Authenticate::LASTFM_REQUEST);\n                        }\n                    },\n                ))\n                .with_spacer(theme::grid(1.0))\n                .with_child(Button::new(\"Request API Key\").on_click(|_, _, _| {\n                    open::that(\"https://www.last.fm/api/account/create\").ok();\n                })),\n        )\n        .with_spacer(theme::grid(1.0))\n        // Last.fm Status label\n        .with_child(ViewSwitcher::new(\n            |data: &AppState, _| {\n                data.preferences\n                    .lastfm_auth_result\n                    .clone()\n                    .unwrap_or_default()\n            },\n            |msg: &String, _, _| {\n                // Only show label if there's an error or connecting message\n                if msg.is_empty() || msg.starts_with(\"Success\") {\n                    SizedBox::empty().boxed()\n                } else {\n                    Label::new(msg.clone())\n                        .with_text_color(if msg.starts_with(\"Connect\") {\n                            druid::Color::GRAY\n                        } else {\n                            druid::Color::RED\n                        })\n                        .boxed()\n                }\n            },\n        ))\n}\n\npub struct Authenticate {\n    tab: AccountTab,\n    spotify_thread: Option<JoinHandle<()>>,\n    lastfm_thread: Option<JoinHandle<()>>,\n}\n\nimpl Authenticate {\n    fn new(tab: AccountTab) -> Self {\n        Self {\n            tab,\n            spotify_thread: None,\n            lastfm_thread: None,\n        }\n    }\n\n    // Helper function to spawn authentication threads\n    fn spawn_auth_thread<T: Send + 'static>(\n        ctx: &mut EventCtx,\n        auth_logic: impl FnOnce() -> Result<T, String> + Send + 'static,\n        response_selector: Selector<Result<T, String>>,\n        existing_handle: Option<JoinHandle<()>>,\n    ) -> Option<JoinHandle<()>> {\n        // Clean up previous thread if any\n        if let Some(_handle) = existing_handle {\n            // Consider if joining is necessary/desirable\n        }\n\n        let window_id = ctx.window_id();\n        let event_sink = ctx.get_external_handle();\n\n        let thread = thread::spawn(move || {\n            let result = auth_logic();\n            event_sink\n                .submit_command(response_selector, result, window_id)\n                .unwrap();\n        });\n        Some(thread)\n    }\n\n    // Helper method to simplify Spotify authentication flow\n    fn start_spotify_auth(&mut self, ctx: &mut EventCtx, data: &mut AppState) {\n        // Set authentication to in-progress state\n        data.preferences.auth.result.defer_default();\n\n        // Generate auth URL and store PKCE verifier\n        let (auth_url, pkce_verifier) = oauth::generate_auth_url(8888);\n        let config = data.preferences.auth.session_config(); // Keep config local\n\n        // Spawn authentication thread\n        self.spotify_thread = Authenticate::spawn_auth_thread(\n            ctx,\n            move || {\n                // Listen for authorization code\n                let code = oauth::get_authcode_listener(\n                    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8888),\n                    Duration::from_secs(300),\n                )\n                .map_err(|e| e.to_string())?;\n\n                // Exchange code for access token\n                let token = oauth::exchange_code_for_token(8888, code, pkce_verifier);\n\n                // Try to authenticate with token, with retries\n                let mut retries = 3;\n                while retries > 0 {\n                    match Authentication::authenticate_and_get_credentials(SessionConfig {\n                        login_creds: Credentials::from_access_token(token.clone()),\n                        ..config.clone()\n                    }) {\n                        Ok(credentials) => return Ok(credentials),\n                        Err(e) if retries > 1 => {\n                            log::warn!(\"authentication failed, retrying: {e:?}\");\n                            retries -= 1;\n                        }\n                        Err(e) => return Err(e),\n                    }\n                }\n                Err(\"Authentication retries exceeded\".to_string())\n            },\n            Self::SPOTIFY_RESPONSE,\n            self.spotify_thread.take(),\n        );\n\n        // Open browser with authorization URL\n        if open::that(&auth_url).is_err() {\n            data.error_alert(\"Failed to open browser\");\n            // Resolve the promise with an error immediately\n            data.preferences\n                .auth\n                .result\n                .reject((), \"Failed to open browser\".to_string());\n        }\n    }\n}\n\nimpl Authenticate {\n    pub const SPOTIFY_REQUEST: Selector =\n        Selector::new(\"app.preferences.spotify.authenticate-request\");\n    pub const SPOTIFY_RESPONSE: Selector<Result<Credentials, String>> =\n        Selector::new(\"app.preferences.spotify.authenticate-response\");\n\n    // Selector for initializing fields\n    pub const INITIALIZE_LASTFM_FIELDS: Selector =\n        Selector::new(\"app.preferences.lastfm.initialize-fields\");\n\n    // Last.fm selectors\n    pub const LASTFM_REQUEST: Selector =\n        Selector::new(\"app.preferences.lastfm.authenticate-request\");\n    pub const LASTFM_RESPONSE: Selector<Result<String, String>> =\n        Selector::new(\"app.preferences.lastfm.authenticate-response\");\n}\n\nimpl<W: Widget<AppState>> Controller<AppState, W> for Authenticate {\n    fn event(\n        &mut self,\n        child: &mut W,\n        ctx: &mut EventCtx,\n        event: &Event,\n        data: &mut AppState,\n        env: &Env,\n    ) {\n        match event {\n            Event::Command(cmd) if cmd.is(Self::SPOTIFY_REQUEST) => {\n                self.start_spotify_auth(ctx, data);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(Self::INITIALIZE_LASTFM_FIELDS) => {\n                data.preferences.auth.lastfm_api_key_input =\n                    data.config.lastfm_api_key.clone().unwrap_or_default();\n                data.preferences.auth.lastfm_api_secret_input =\n                    data.config.lastfm_api_secret.clone().unwrap_or_default();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(cmd::LOG_OUT) => {\n                data.config.clear_credentials();\n                data.config.save();\n                data.session.shutdown();\n                ctx.submit_command(cmd::CLOSE_ALL_WINDOWS);\n                ctx.submit_command(cmd::SHOW_ACCOUNT_SETUP);\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(Self::LASTFM_REQUEST) => {\n                // Use the temporary input fields from preferences state.\n                let api_key = data.preferences.auth.lastfm_api_key_input.clone();\n                let api_secret = data.preferences.auth.lastfm_api_secret_input.clone();\n\n                if api_key.is_empty() || api_secret.is_empty() {\n                    data.preferences.lastfm_auth_result =\n                        Some(\"API Key and Secret required.\".to_string());\n                    ctx.set_handled();\n                    return;\n                }\n\n                data.preferences.lastfm_auth_result = Some(\"Connecting...\".to_string());\n                let port = 8889;\n                let callback_url = format!(\"http://127.0.0.1:{port}/lastfm_callback\");\n                let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port);\n\n                match lastfm::generate_lastfm_auth_url(&api_key, &callback_url) {\n                    Ok(auth_url) => {\n                        self.lastfm_thread = Authenticate::spawn_auth_thread(\n                            ctx,\n                            move || {\n                                let token = lastfm::get_lastfm_token_listener(\n                                    socket_addr,\n                                    Duration::from_secs(300),\n                                )\n                                .map_err(|e| e.to_string())?;\n                                log::info!(\"received Last.fm token, exchanging...\");\n                                lastfm::exchange_token_for_session(&api_key, &api_secret, &token)\n                                    .map_err(|e| format!(\"Token exchange failed: {e}\"))\n                            },\n                            Self::LASTFM_RESPONSE,\n                            self.lastfm_thread.take(),\n                        );\n\n                        if open::that(&auth_url).is_err() {\n                            data.preferences.lastfm_auth_result =\n                                Some(\"Failed to open browser.\".to_string());\n                            // No promise to reject here, just update the status message\n                        }\n                    }\n                    Err(e) => {\n                        data.preferences.lastfm_auth_result =\n                            Some(format!(\"Failed to create auth URL: {e}\"));\n                    }\n                }\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(Self::SPOTIFY_RESPONSE) => {\n                let result = cmd.get_unchecked(Self::SPOTIFY_RESPONSE);\n                match result {\n                    Ok(credentials) => {\n                        // Update session config with the new credentials\n                        data.session.update_config(SessionConfig {\n                            login_creds: credentials.clone(),\n                            proxy_url: Config::proxy(),\n                        });\n                        data.config.store_credentials(credentials.clone());\n                        data.config.save();\n                        data.preferences.auth.result.resolve((), ());\n                        // Handle UI flow based on tab type\n                        if matches!(self.tab, AccountTab::FirstSetup) {\n                            ctx.submit_command(cmd::CLOSE_ALL_WINDOWS);\n                            ctx.submit_command(cmd::SHOW_MAIN);\n                        }\n                    }\n                    Err(err) => {\n                        data.preferences.auth.result.reject((), err.clone());\n                    }\n                }\n                self.spotify_thread.take();\n                ctx.set_handled();\n            }\n            Event::Command(cmd) if cmd.is(Self::LASTFM_RESPONSE) => {\n                let result = cmd.get_unchecked(Self::LASTFM_RESPONSE);\n                match result {\n                    Ok(session_key) => {\n                        // On success, store the validated key/secret in config and save.\n                        data.config.lastfm_api_key =\n                            Some(data.preferences.auth.lastfm_api_key_input.clone());\n                        data.config.lastfm_api_secret =\n                            Some(data.preferences.auth.lastfm_api_secret_input.clone());\n                        data.config.lastfm_session_key = Some(session_key.clone());\n                        data.config.save();\n\n                        log::info!(\"Last.fm session key stored successfully.\");\n\n                        data.preferences.lastfm_auth_result =\n                            Some(\"Success! Last.fm connected.\".to_string());\n                    }\n                    Err(err) => {\n                        data.preferences.lastfm_auth_result = Some(err.clone());\n                    }\n                }\n                self.lastfm_thread.take();\n                ctx.set_handled();\n            }\n            _ => {\n                child.event(ctx, event, data, env);\n            }\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        child: &mut W,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &AppState,\n        env: &Env,\n    ) {\n        if let LifeCycle::WidgetAdded = event {\n            ctx.submit_command(Self::INITIALIZE_LASTFM_FIELDS);\n        }\n        child.lifecycle(ctx, event, data, env);\n    }\n}\n\nfn cache_tab_widget() -> impl Widget<AppState> {\n    let mut col = Flex::column().cross_axis_alignment(CrossAxisAlignment::Start);\n\n    col = col\n        .with_child(Label::new(\"Location\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(\n            Label::dynamic(|_, _| {\n                Config::cache_dir()\n                    .map(|path| path.to_string_lossy().to_string())\n                    .unwrap_or_else(|| \"None\".to_string())\n            })\n            .with_line_break_mode(LineBreaking::WordWrap),\n        );\n\n    col = col.with_spacer(theme::grid(3.0));\n\n    col = col\n        .with_child(Label::new(\"Size\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(Label::dynamic(\n            |preferences: &Preferences, _| match preferences.cache_size {\n                Promise::Empty | Promise::Rejected { .. } => \"Unknown\".to_string(),\n                Promise::Deferred { .. } => \"Computing...\".to_string(),\n                Promise::Resolved { val: 0, .. } => \"Empty\".to_string(),\n                Promise::Resolved { val, .. } => {\n                    format!(\"{:.2} MB\", val as f64 / 1e6_f64)\n                }\n            },\n        ));\n\n    // Clear cache button\n    col = col\n        .with_spacer(theme::grid(2.0))\n        .with_child(Button::new(\"Clear Cache\").on_left_click(|ctx, _, _, _| {\n            ctx.submit_command(CLEAR_CACHE);\n        }));\n\n    col.controller(CacheController::new())\n        .lens(AppState::preferences)\n}\n\nfn about_tab_widget() -> impl Widget<AppState> {\n    // Build Info\n    let commit_hash = Flex::row()\n        .with_child(Label::new(\"Commit Hash:   \"))\n        .with_child(Label::new(psst_core::GIT_VERSION).with_text_color(theme::DISABLED_TEXT_COLOR));\n\n    let build_time = Flex::row()\n        .with_child(Label::new(\"Build time:   \"))\n        .with_child(Label::new(psst_core::BUILD_TIME).with_text_color(theme::DISABLED_TEXT_COLOR));\n\n    let remote_url = Flex::row().with_child(Label::new(\"Source:   \")).with_child(\n        Label::new(psst_core::REMOTE_URL)\n            .with_text_color(Color::rgb8(138, 180, 248))\n            .on_left_click(|_, _, _, _| {\n                open::that(psst_core::REMOTE_URL).ok();\n            }),\n    );\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .must_fill_main_axis(true)\n        .with_child(Label::new(\"Build Info\").with_font(theme::UI_FONT_MEDIUM))\n        .with_spacer(theme::grid(2.0))\n        .with_child(commit_hash)\n        .with_child(build_time)\n        .with_child(remote_url)\n}\n"
  },
  {
    "path": "psst-gui/src/ui/recommend.rs",
    "content": "use std::{sync::Arc, time::Duration};\n\nuse druid::{\n    widget::{CrossAxisAlignment, Flex, Slider},\n    FontDescriptor, FontFamily, LensExt, Selector, Widget, WidgetExt,\n};\n\nuse crate::{\n    data::{\n        AppState, Ctx, Recommend, Recommendations, RecommendationsKnobs, RecommendationsParams,\n        RecommendationsRequest, Toggled, WithCtx,\n    },\n    webapi::WebApi,\n    widget::{Async, Checkbox, MyWidgetExt},\n};\n\nuse super::{playable, theme, track, utils};\n\nconst KNOBS_DEBOUNCE_DELAY: Duration = Duration::from_millis(500);\n\npub const UPDATE_PARAMS: Selector<RecommendationsParams> =\n    Selector::new(\"app.recommend.update-params\");\npub const LOAD_RESULTS: Selector<Arc<RecommendationsRequest>> =\n    Selector::new(\"app.recommend.load-results\");\n\npub fn results_widget() -> impl Widget<AppState> {\n    let track_results = Async::new(\n        utils::spinner_widget,\n        track_results_widget,\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::recommend.then(Recommend::results),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_RESULTS,\n        |d| WebApi::global().get_recommendations(d),\n        |_, data, d| data.recommend.results.defer(d),\n        |_, data, r| data.recommend.results.update(r),\n    )\n    .on_command(UPDATE_PARAMS, |ctx, params, data| {\n        if let Some(previous) = data.recommend.results.deferred() {\n            let previous = (**previous).clone();\n            let params = params.to_owned();\n            let request = previous.with_params(params);\n            ctx.submit_command(LOAD_RESULTS.with(Arc::new(request)));\n        }\n    });\n\n    let param_knobs = params_widget()\n        .on_debounce(KNOBS_DEBOUNCE_DELAY, |ctx, knobs, _| {\n            ctx.submit_command(UPDATE_PARAMS.with(knobs.as_params()));\n        })\n        .lens(AppState::recommend.then(Recommend::knobs));\n\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(param_knobs)\n        .with_default_spacer()\n        .with_child(track_results)\n}\n\nfn params_widget() -> impl Widget<Arc<RecommendationsKnobs>> {\n    let row = |label| {\n        Flex::column()\n            .cross_axis_alignment(CrossAxisAlignment::Start)\n            .with_child(\n                Checkbox::new(label)\n                    .lens(Toggled::enabled)\n                    .env_scope(|env, _| {\n                        env.set(theme::BASIC_WIDGET_HEIGHT, theme::grid(1.5));\n                        env.set(\n                            theme::UI_FONT,\n                            FontDescriptor::new(FontFamily::SYSTEM_UI)\n                                .with_size(env.get(theme::TEXT_SIZE_SMALL)),\n                        );\n                    }),\n            )\n            .with_spacer(theme::grid(0.4))\n            .with_child(\n                Slider::new()\n                    .lens(Toggled::value)\n                    .disabled_if(|toggle, _| !toggle.enabled)\n                    .padding_left(theme::grid(2.5))\n                    .env_scope(|env, _| {\n                        env.set(theme::BASIC_WIDGET_HEIGHT, theme::grid(1.5));\n                        env.set(theme::FOREGROUND_LIGHT, env.get(theme::GREY_400));\n                        env.set(theme::FOREGROUND_DARK, env.get(theme::GREY_400));\n                        env.set(theme::DISABLED_FOREGROUND_LIGHT, env.get(theme::GREY_600));\n                        env.set(theme::DISABLED_FOREGROUND_DARK, env.get(theme::GREY_600));\n                    }),\n            )\n            .padding(theme::grid(0.5))\n    };\n    Flex::row()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_flex_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(row(\"Acousticness\").lens(RecommendationsKnobs::acousticness.in_arc()))\n                .with_child(row(\"Danceability\").lens(RecommendationsKnobs::danceability.in_arc()))\n                .with_child(row(\"Energy\").lens(RecommendationsKnobs::energy.in_arc())),\n            1.0,\n        )\n        .with_flex_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(\n                    row(\"Instrumentalness\").lens(RecommendationsKnobs::instrumentalness.in_arc()),\n                )\n                .with_child(row(\"Liveness\").lens(RecommendationsKnobs::liveness.in_arc()))\n                .with_child(row(\"Loudness\").lens(RecommendationsKnobs::loudness.in_arc())),\n            1.0,\n        )\n        .with_flex_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(row(\"Speechiness\").lens(RecommendationsKnobs::speechiness.in_arc()))\n                .with_child(row(\"Valence\").lens(RecommendationsKnobs::valence.in_arc())),\n            1.0,\n        )\n}\n\nfn track_results_widget() -> impl Widget<WithCtx<Recommendations>> {\n    playable::list_widget(playable::Display {\n        track: track::Display {\n            title: true,\n            artist: true,\n            album: true,\n            cover: true,\n            ..track::Display::empty()\n        },\n    })\n}\n"
  },
  {
    "path": "psst-gui/src/ui/search.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{\n        CrossAxisAlignment, Either, Flex, Label, LabelText, List, MainAxisAlignment, Scroll,\n        TextBox,\n    },\n    Data, Env, Insets, Lens, LensExt, RenderContext, Selector, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    controller::InputController,\n    data::{AppState, Ctx, Nav, Search, SearchResults, SearchTopic, SpotifyUrl, WithCtx},\n    ui::show,\n    webapi::WebApi,\n    widget::{Async, Empty, MyWidgetExt},\n};\n\nuse super::{album, artist, playable, playlist, theme, track, utils};\n\nconst NUMBER_OF_RESULTS_PER_TOPIC: usize = 5;\nconst INDIVIDUAL_TOPIC_RESULTS_LIMIT: usize = 50;\n\npub const LOAD_RESULTS: Selector<(Arc<str>, Option<SearchTopic>)> =\n    Selector::new(\"app.search.load-results\");\npub const OPEN_LINK: Selector<SpotifyUrl> = Selector::new(\"app.search.open-link\");\npub const SET_TOPIC: Selector<Option<SearchTopic>> = Selector::new(\"app.search.set-topic\");\n\npub fn input_widget() -> impl Widget<AppState> {\n    TextBox::new()\n        .with_placeholder(\"Search\")\n        .controller(InputController::new().on_submit(|ctx, query, _| {\n            if query.trim().is_empty() {\n                return;\n            }\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::SearchResults(query.clone().into())));\n        }))\n        .with_id(cmd::WIDGET_SEARCH_INPUT)\n        .expand_width()\n        .lens(AppState::search.then(Search::input))\n}\n\npub fn results_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Center)\n        .with_spacer(theme::grid(1.0))\n        .with_child(topic_widget())\n        .with_flex_child(Scroll::new(async_results_widget()).vertical(), 1.0)\n}\n\nfn topic_widget() -> impl Widget<AppState> {\n    let mut topics = Flex::row();\n\n    topics.add_child(topic_button(\"All\", None));\n\n    for &topic in SearchTopic::all() {\n        topics.add_default_spacer();\n        topics.add_child(topic_button(topic.display_name(), Some(topic)));\n    }\n\n    Scroll::new(\n        topics\n            .main_axis_alignment(MainAxisAlignment::Center)\n            .padding(Insets::new(0.0, 0.0, 0.0, theme::grid(2.0))),\n    )\n    .horizontal()\n}\n\nfn topic_button(label: &str, topic: Option<SearchTopic>) -> impl Widget<AppState> {\n    Label::new(label)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .padding(Insets::uniform_xy(theme::grid(1.5), theme::grid(0.5)))\n        .background(druid::widget::Painter::new(\n            move |ctx, data: &AppState, env: &Env| {\n                let is_selected = data.search.topic == topic;\n                let color = if is_selected {\n                    env.get(theme::GREY_500)\n                } else if ctx.is_hot() {\n                    env.get(theme::GREY_600)\n                } else {\n                    env.get(theme::GREY_700)\n                };\n                let bounds = ctx\n                    .size()\n                    .to_rounded_rect(env.get(theme::BUTTON_BORDER_RADIUS));\n                ctx.fill(bounds, &color);\n            },\n        ))\n        .link()\n        .on_click(move |ctx, _, _| {\n            ctx.submit_command(SET_TOPIC.with(topic));\n        })\n}\n\nfn async_results_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        loaded_results_widget,\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(AppState::common_ctx, AppState::search.then(Search::results))\n            .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_RESULTS,\n        |(q, t)| {\n            let topics = t\n                .map(|t| vec![t])\n                .unwrap_or_else(|| SearchTopic::all().to_vec());\n            let limit = if topics.len() == 1 {\n                INDIVIDUAL_TOPIC_RESULTS_LIMIT\n            } else {\n                NUMBER_OF_RESULTS_PER_TOPIC\n            };\n            WebApi::global().search(&q, &topics, limit)\n        },\n        |_, data, (q, t)| data.search.results.defer((q, t)),\n        |_, data, r| data.search.results.update(r),\n    )\n    .on_command(SET_TOPIC, |ctx, topic, data: &mut AppState| {\n        data.search.topic = *topic;\n        if !data.search.input.is_empty() {\n            ctx.submit_command(\n                LOAD_RESULTS.with((data.search.input.clone().into(), data.search.topic)),\n            );\n        }\n    })\n    .on_command_async(\n        OPEN_LINK,\n        |l| WebApi::global().load_spotify_link(&l),\n        |_, data, l| data.search.results.defer((l.id(), None)),\n        |ctx, data, (l, r)| match r {\n            Ok(nav) => {\n                data.search.results.clear();\n                ctx.submit_command(cmd::NAVIGATE.with(nav));\n            }\n            Err(err) => {\n                data.search.results.reject((l.id(), None), err);\n            }\n        },\n    )\n}\n\nfn loaded_results_widget() -> impl Widget<WithCtx<SearchResults>> {\n    Either::new(\n        |results: &WithCtx<SearchResults>, _| results.data.is_empty(),\n        Label::new(\"No results\")\n            .with_text_size(theme::TEXT_SIZE_LARGE)\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .padding(theme::grid(6.0))\n            .center(),\n        Either::new(\n            |results: &WithCtx<SearchResults>, _| results.data.topic.is_some(),\n            results_list(false),\n            results_list(true),\n        ),\n    )\n}\n\nfn results_list(include_headers: bool) -> Flex<WithCtx<SearchResults>> {\n    let mut column = Flex::column().cross_axis_alignment(CrossAxisAlignment::Fill);\n    column = column.with_child(artist_results_widget(include_headers));\n    column = column.with_child(album_results_widget(include_headers));\n    column = column.with_child(track_results_widget(include_headers));\n    column = column.with_child(playlist_results_widget(include_headers));\n    column.with_child(show_results_widget(include_headers))\n}\n\nfn section_widget<T: Data, W: Widget<T> + 'static>(\n    header: &str,\n    include_header: bool,\n    lens: impl Lens<WithCtx<SearchResults>, T> + 'static,\n    is_empty: impl Fn(&T) -> bool + 'static,\n    content: impl Fn() -> W + 'static,\n) -> impl Widget<WithCtx<SearchResults>> {\n    let header_text = header.to_string();\n    Either::new(move |data: &T, _| is_empty(data), Empty, {\n        let mut column = Flex::column();\n        if include_header {\n            column = column.with_child(header_widget(header_text.clone()));\n        }\n        column.with_child(content())\n    })\n    .lens(lens)\n}\n\nfn artist_results_widget(include_header: bool) -> impl Widget<WithCtx<SearchResults>> {\n    section_widget(\n        SearchTopic::Artist.display_name(),\n        include_header,\n        Ctx::data().then(SearchResults::artists),\n        |artists| artists.is_empty(),\n        || List::new(|| artist::artist_widget(false)),\n    )\n}\n\nfn album_results_widget(include_header: bool) -> impl Widget<WithCtx<SearchResults>> {\n    section_widget(\n        SearchTopic::Album.display_name(),\n        include_header,\n        Ctx::map(SearchResults::albums),\n        |albums| albums.data.is_empty(),\n        || List::new(|| album::album_widget(false)),\n    )\n}\n\nfn track_results_widget(include_header: bool) -> impl Widget<WithCtx<SearchResults>> {\n    section_widget(\n        SearchTopic::Track.display_name(),\n        include_header,\n        druid::lens::Identity,\n        |results| results.data.tracks.is_empty(),\n        || {\n            playable::list_widget(playable::Display {\n                track: track::Display {\n                    title: true,\n                    artist: true,\n                    album: true,\n                    cover: true,\n                    ..track::Display::empty()\n                },\n            })\n        },\n    )\n}\n\nfn playlist_results_widget(include_header: bool) -> impl Widget<WithCtx<SearchResults>> {\n    section_widget(\n        SearchTopic::Playlist.display_name(),\n        include_header,\n        Ctx::map(SearchResults::playlists),\n        |playlists| playlists.data.is_empty(),\n        || List::new(|| playlist::playlist_widget(false)),\n    )\n}\n\nfn show_results_widget(include_header: bool) -> impl Widget<WithCtx<SearchResults>> {\n    section_widget(\n        SearchTopic::Show.display_name(),\n        include_header,\n        Ctx::map(SearchResults::shows),\n        |shows| shows.data.is_empty(),\n        || List::new(|| show::show_widget(false)),\n    )\n}\n\nfn header_widget<T: Data>(text: impl Into<LabelText<T>>) -> impl Widget<T> {\n    Label::new(text)\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .padding((0.0, theme::grid(2.0), 0.0, theme::grid(1.0)))\n}\n"
  },
  {
    "path": "psst-gui/src/ui/show.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{CrossAxisAlignment, Flex, Label, LineBreaking, Scroll},\n    LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt,\n};\n\nuse crate::{\n    cmd,\n    data::{AppState, Ctx, Library, Nav, Show, ShowDetail, ShowEpisodes, ShowLink, WithCtx},\n    ui::utils::{stat_row, InfoLayout},\n    webapi::WebApi,\n    widget::{Async, MyWidgetExt, RemoteImage},\n};\n\nuse super::{library, playable, theme, track, utils};\n\npub const LOAD_DETAIL: Selector<ShowLink> = Selector::new(\"app.show.load-detail\");\n\npub fn detail_widget() -> impl Widget<AppState> {\n    Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(async_info_widget())\n        .with_default_spacer()\n        .with_child(async_episodes_widget())\n}\n\nfn async_info_widget() -> impl Widget<AppState> {\n    Async::new(utils::spinner_widget, info_widget, utils::error_widget)\n        .lens(\n            Ctx::make(\n                AppState::common_ctx,\n                AppState::show_detail.then(ShowDetail::show),\n            )\n            .then(Ctx::in_promise()),\n        )\n        .on_command_async(\n            LOAD_DETAIL,\n            |d| WebApi::global().get_show(&d.id),\n            |_, data, d| data.show_detail.show.defer(d),\n            |_, data, (d, r)| {\n                data.show_detail\n                    .show\n                    .update((d, r.map(|cached| cached.data)))\n            },\n        )\n}\n\nfn info_widget() -> impl Widget<WithCtx<Arc<Show>>> {\n    let size = theme::grid(16.0);\n\n    let image = rounded_cover_widget(size)\n        .fix_size(size, size)\n        .clip(Size::new(size, size).to_rounded_rect(4.0))\n        .lens(Ctx::data())\n        .context_menu(show_ctx_menu);\n\n    let biography = Scroll::new(\n        Label::new(|data: &Arc<Show>, _env: &_| data.description.clone())\n            .with_line_break_mode(LineBreaking::WordWrap)\n            .with_text_size(theme::TEXT_SIZE_NORMAL),\n    )\n    .vertical()\n    .lens(Ctx::data());\n\n    let stats = Flex::column()\n        .with_child(stat_row(\"Publisher:\", |info: &Arc<Show>| {\n            if info.publisher.is_empty() {\n                String::new()\n            } else {\n                info.publisher.to_string()\n            }\n        }))\n        .with_default_spacer()\n        .with_child(stat_row(\"Episodes:\", |info: &Arc<Show>| {\n            match info.total_episodes {\n                Some(count) => format!(\"{} episode{}\", count, if count == 1 { \"\" } else { \"s\" }),\n                None => String::new(),\n            }\n        }));\n\n    let me = InfoLayout::new(biography, stats);\n\n    Flex::row()\n        .with_child(image)\n        .with_spacer(theme::grid(1.0))\n        .with_flex_child(me, 1.0)\n        .padding(theme::grid(1.0))\n}\n\nfn async_episodes_widget() -> impl Widget<AppState> {\n    Async::new(\n        utils::spinner_widget,\n        || {\n            playable::list_widget(playable::Display {\n                track: track::Display::empty(),\n            })\n        },\n        utils::error_widget,\n    )\n    .lens(\n        Ctx::make(\n            AppState::common_ctx,\n            AppState::show_detail.then(ShowDetail::episodes),\n        )\n        .then(Ctx::in_promise()),\n    )\n    .on_command_async(\n        LOAD_DETAIL,\n        |d| WebApi::global().get_show_episodes(&d.id),\n        |_, data, d| data.show_detail.episodes.defer(d),\n        |_, data, (d, r)| {\n            let r = r.map(|episodes| ShowEpisodes {\n                show: d.clone(),\n                episodes,\n            });\n            data.show_detail.episodes.update((d, r))\n        },\n    )\n}\n\npub fn show_widget(horizontal: bool) -> impl Widget<WithCtx<Arc<Show>>> {\n    let image_size = theme::grid(if horizontal { 16.0 } else { 6.0 });\n    let show_image = rounded_cover_widget(image_size);\n\n    let show_name = Label::raw()\n        .with_font(theme::UI_FONT_MEDIUM)\n        .with_line_break_mode(LineBreaking::Clip)\n        .lens(Show::name.in_arc())\n        .align_left();\n\n    let show_publisher = Label::<Arc<Show>>::dynamic(|show, _| {\n        if !show.publisher.is_empty() {\n            show.publisher.to_string()\n        } else {\n            String::new()\n        }\n    })\n    .with_line_break_mode(LineBreaking::Clip)\n    .with_text_size(theme::TEXT_SIZE_SMALL)\n    .with_text_color(theme::PLACEHOLDER_COLOR)\n    .align_left();\n\n    let show_episodes = Label::<Arc<Show>>::dynamic(|show, _| match show.total_episodes {\n        Some(count) => format!(\"{} episode{}\", count, if count == 1 { \"\" } else { \"s\" }),\n        None => String::new(),\n    })\n    .with_line_break_mode(LineBreaking::Clip)\n    .with_text_size(theme::TEXT_SIZE_SMALL)\n    .with_text_color(theme::PLACEHOLDER_COLOR)\n    .align_left();\n\n    let show = if horizontal {\n        Flex::column()\n            .with_child(show_image)\n            .with_default_spacer()\n            .with_child(\n                Flex::column()\n                    .with_child(show_name)\n                    .with_child(show_publisher)\n                    .with_child(show_episodes)\n                    .align_horizontal(UnitPoint::CENTER)\n                    .align_vertical(UnitPoint::TOP)\n                    .fix_size(theme::grid(16.0), theme::grid(8.0)),\n            )\n            .padding(theme::grid(1.0))\n            .lens(Ctx::data())\n    } else {\n        Flex::row()\n            .with_child(show_image)\n            .with_default_spacer()\n            .with_flex_child(\n                Flex::column()\n                    .with_child(show_name)\n                    .with_child(show_publisher)\n                    .with_child(show_episodes),\n                1.0,\n            )\n            .padding(theme::grid(1.0))\n            .lens(Ctx::data())\n    };\n\n    show.align_left()\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, show, _| {\n            ctx.submit_command(cmd::NAVIGATE.with(Nav::ShowDetail(show.data.link())));\n        })\n        .context_menu(show_ctx_menu)\n}\n\nfn cover_widget(size: f64) -> impl Widget<Arc<Show>> {\n    RemoteImage::new(utils::placeholder_widget(), move |show: &Arc<Show>, _| {\n        show.image(size, size).map(|image| image.url.clone())\n    })\n    .fix_size(size, size)\n}\n\nfn rounded_cover_widget(size: f64) -> impl Widget<Arc<Show>> {\n    // TODO: Take the radius from theme.\n    cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0))\n}\n\nfn show_ctx_menu(show: &WithCtx<Arc<Show>>) -> Menu<AppState> {\n    show_menu(&show.data, &show.ctx.library)\n}\n\nfn show_menu(show: &Arc<Show>, library: &Arc<Library>) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Show\"),\n        )\n        .command(cmd::COPY.with(show.link().url())),\n    );\n\n    menu = menu.separator();\n\n    if library.contains_show(show) {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-remove-from-library\").with_placeholder(\"Unfollow\"),\n            )\n            .command(library::UNSAVE_SHOW.with(show.link())),\n        );\n    } else {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-save-to-library\").with_placeholder(\"Follow\"),\n            )\n            .command(library::SAVE_SHOW.with(show.clone())),\n        );\n    }\n\n    menu\n}\n"
  },
  {
    "path": "psst-gui/src/ui/theme.rs",
    "content": "use druid::{Color, Env, FontDescriptor, FontFamily, FontWeight, Insets, Key, Size};\n\npub use druid::theme::*;\n\nuse crate::data::{AppState, Theme};\n\npub fn grid(m: f64) -> f64 {\n    GRID * m\n}\n\npub const GRID: f64 = 8.0;\n\npub const GREY_000: Key<Color> = Key::new(\"app.grey_000\");\npub const GREY_100: Key<Color> = Key::new(\"app.grey_100\");\npub const GREY_200: Key<Color> = Key::new(\"app.grey_200\");\npub const GREY_300: Key<Color> = Key::new(\"app.grey_300\");\npub const GREY_400: Key<Color> = Key::new(\"app.grey_400\");\npub const GREY_500: Key<Color> = Key::new(\"app.grey_500\");\npub const GREY_600: Key<Color> = Key::new(\"app.grey_600\");\npub const GREY_700: Key<Color> = Key::new(\"app.grey_700\");\npub const BLUE_100: Key<Color> = Key::new(\"app.blue_100\");\npub const BLUE_200: Key<Color> = Key::new(\"app.blue_200\");\n\npub const RED: Key<Color> = Key::new(\"app.red\");\n\npub const MENU_BUTTON_BG_ACTIVE: Key<Color> = Key::new(\"app.menu-bg-active\");\npub const MENU_BUTTON_BG_INACTIVE: Key<Color> = Key::new(\"app.menu-bg-inactive\");\npub const MENU_BUTTON_FG_ACTIVE: Key<Color> = Key::new(\"app.menu-fg-active\");\npub const MENU_BUTTON_FG_INACTIVE: Key<Color> = Key::new(\"app.menu-fg-inactive\");\n\npub const UI_FONT_MEDIUM: Key<FontDescriptor> = Key::new(\"app.ui-font-medium\");\npub const UI_FONT_MONO: Key<FontDescriptor> = Key::new(\"app.ui-font-mono\");\npub const TEXT_SIZE_SMALL: Key<f64> = Key::new(\"app.text-size-small\");\n\npub const ICON_COLOR: Key<Color> = Key::new(\"app.icon-color\");\npub const ICON_SIZE_TINY: Size = Size::new(12.0, 12.0);\npub const ICON_SIZE_SMALL: Size = Size::new(14.0, 14.0);\npub const ICON_SIZE_MEDIUM: Size = Size::new(16.0, 16.0);\npub const ICON_SIZE_LARGE: Size = Size::new(22.0, 22.0);\n\npub const LINK_HOT_COLOR: Key<Color> = Key::new(\"app.link-hot-color\");\npub const LINK_ACTIVE_COLOR: Key<Color> = Key::new(\"app.link-active-color\");\npub const LINK_COLD_COLOR: Key<Color> = Key::new(\"app.link-cold-color\");\n\npub fn setup(env: &mut Env, state: &AppState) {\n    match state.config.theme {\n        Theme::Light => setup_light_theme(env),\n        Theme::Dark => setup_dark_theme(env),\n    };\n\n    env.set(WINDOW_BACKGROUND_COLOR, env.get(GREY_700));\n    env.set(TEXT_COLOR, env.get(GREY_100));\n    env.set(ICON_COLOR, env.get(GREY_400));\n    env.set(PLACEHOLDER_COLOR, env.get(GREY_300));\n    env.set(PRIMARY_LIGHT, env.get(BLUE_100));\n    env.set(PRIMARY_DARK, env.get(BLUE_200));\n\n    env.set(BACKGROUND_LIGHT, env.get(GREY_700));\n    env.set(BACKGROUND_DARK, env.get(GREY_600));\n    env.set(FOREGROUND_LIGHT, env.get(GREY_100));\n    env.set(FOREGROUND_DARK, env.get(GREY_000));\n\n    match state.config.theme {\n        Theme::Light => {\n            env.set(BUTTON_LIGHT, env.get(GREY_700));\n            env.set(BUTTON_DARK, env.get(GREY_600));\n        }\n        Theme::Dark => {\n            env.set(BUTTON_LIGHT, env.get(GREY_600));\n            env.set(BUTTON_DARK, env.get(GREY_700));\n        }\n    }\n\n    env.set(BORDER_LIGHT, env.get(GREY_400));\n    env.set(BORDER_DARK, env.get(GREY_500));\n\n    env.set(SELECTED_TEXT_BACKGROUND_COLOR, env.get(BLUE_200));\n    env.set(SELECTION_TEXT_COLOR, env.get(GREY_700));\n\n    env.set(CURSOR_COLOR, env.get(GREY_000));\n\n    env.set(PROGRESS_BAR_RADIUS, 4.0);\n    env.set(BUTTON_BORDER_RADIUS, 4.0);\n    env.set(BUTTON_BORDER_WIDTH, 1.0);\n\n    env.set(\n        UI_FONT,\n        FontDescriptor::new(FontFamily::SYSTEM_UI).with_size(13.0),\n    );\n    env.set(\n        UI_FONT_MEDIUM,\n        FontDescriptor::new(FontFamily::SYSTEM_UI)\n            .with_size(13.0)\n            .with_weight(FontWeight::MEDIUM),\n    );\n    env.set(\n        UI_FONT_MONO,\n        FontDescriptor::new(FontFamily::MONOSPACE).with_size(13.0),\n    );\n    env.set(TEXT_SIZE_SMALL, 11.0);\n    env.set(TEXT_SIZE_NORMAL, 13.0);\n    env.set(TEXT_SIZE_LARGE, 16.0);\n\n    env.set(BASIC_WIDGET_HEIGHT, 16.0);\n    env.set(WIDE_WIDGET_WIDTH, grid(12.0));\n    env.set(BORDERED_WIDGET_HEIGHT, grid(4.0));\n\n    env.set(TEXTBOX_BORDER_RADIUS, 4.0);\n    env.set(TEXTBOX_BORDER_WIDTH, 1.0);\n    env.set(TEXTBOX_INSETS, Insets::uniform_xy(grid(1.2), grid(1.0)));\n\n    env.set(SCROLLBAR_COLOR, env.get(GREY_300));\n    env.set(SCROLLBAR_BORDER_COLOR, env.get(GREY_300));\n    env.set(SCROLLBAR_MAX_OPACITY, 0.8);\n    env.set(SCROLLBAR_FADE_DELAY, 1500u64);\n    env.set(SCROLLBAR_WIDTH, 6.0);\n    env.set(SCROLLBAR_PAD, 2.0);\n    env.set(SCROLLBAR_RADIUS, 5.0);\n    env.set(SCROLLBAR_EDGE_WIDTH, 1.0);\n\n    env.set(WIDGET_PADDING_VERTICAL, grid(0.5));\n    env.set(WIDGET_PADDING_HORIZONTAL, grid(1.0));\n    env.set(WIDGET_CONTROL_COMPONENT_PADDING, grid(1.0));\n\n    env.set(MENU_BUTTON_BG_ACTIVE, env.get(GREY_500));\n    env.set(MENU_BUTTON_BG_INACTIVE, env.get(GREY_600));\n    env.set(MENU_BUTTON_FG_ACTIVE, env.get(GREY_000));\n    env.set(MENU_BUTTON_FG_INACTIVE, env.get(GREY_100));\n}\n\nfn setup_light_theme(env: &mut Env) {\n    env.set(GREY_000, Color::grey8(0x00));\n    env.set(GREY_100, Color::grey8(0x33));\n    env.set(GREY_200, Color::grey8(0x4f));\n    env.set(GREY_300, Color::grey8(0x82));\n    env.set(GREY_400, Color::grey8(0xbd));\n    env.set(GREY_500, Color::from_rgba32_u32(0xe5e6e7ff));\n    env.set(GREY_600, Color::from_rgba32_u32(0xf5f6f7ff));\n    env.set(GREY_700, Color::from_rgba32_u32(0xffffffff));\n    env.set(BLUE_100, Color::rgb8(0x5c, 0xc4, 0xff));\n    env.set(BLUE_200, Color::rgb8(0x00, 0x8d, 0xdd));\n\n    env.set(RED, Color::rgba8(0xEB, 0x57, 0x57, 0xFF));\n\n    env.set(LINK_HOT_COLOR, Color::rgba(0.0, 0.0, 0.0, 0.06));\n    env.set(LINK_ACTIVE_COLOR, Color::rgba(0.0, 0.0, 0.0, 0.04));\n    env.set(LINK_COLD_COLOR, Color::rgba(0.0, 0.0, 0.0, 0.0));\n}\n\nfn setup_dark_theme(env: &mut Env) {\n    env.set(GREY_000, Color::grey8(0xff));\n    env.set(GREY_100, Color::grey8(0xf2));\n    env.set(GREY_200, Color::grey8(0xe0));\n    env.set(GREY_300, Color::grey8(0xbd));\n    env.set(GREY_400, Color::grey8(0x82));\n    env.set(GREY_500, Color::grey8(0x4f));\n    env.set(GREY_600, Color::grey8(0x33));\n    env.set(GREY_700, Color::grey8(0x28));\n    env.set(BLUE_100, Color::rgb8(0x00, 0x8d, 0xdd));\n    env.set(BLUE_200, Color::rgb8(0x5c, 0xc4, 0xff));\n\n    env.set(RED, Color::rgba8(0xEB, 0x57, 0x57, 0xFF));\n\n    env.set(LINK_HOT_COLOR, Color::rgba(1.0, 1.0, 1.0, 0.05));\n    env.set(LINK_ACTIVE_COLOR, Color::rgba(1.0, 1.0, 1.0, 0.025));\n    env.set(LINK_COLD_COLOR, Color::rgba(1.0, 1.0, 1.0, 0.0));\n}\n"
  },
  {
    "path": "psst-gui/src/ui/track.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{CrossAxisAlignment, Either, Flex, Label, LineBreaking, ViewSwitcher},\n    Env, Lens, LensExt, LocalizedString, Menu, MenuItem, Size, TextAlignment, Widget, WidgetExt,\n};\nuse psst_core::{\n    audio::normalize::NormalizationLevel,\n    item_id::{ItemId, ItemIdType},\n    player::item::PlaybackItem,\n};\n\nuse crate::{\n    cmd,\n    data::{\n        AppState, Library, Nav, Playable, PlaybackOrigin, PlaylistAddTrack, PlaylistRemoveTrack,\n        QueueEntry, RecommendationsRequest, Track,\n    },\n    ui::playlist,\n    widget::{fill_between::FillBetween, icons, Empty, MyWidgetExt, RemoteImage},\n};\n\nuse super::{\n    library,\n    playable::{self, PlayRow},\n    theme,\n    utils::{self, placeholder_widget},\n};\n\n#[derive(Copy, Clone)]\npub struct Display {\n    pub number: bool,\n    pub title: bool,\n    pub artist: bool,\n    pub album: bool,\n    pub cover: bool,\n    pub popularity: bool,\n}\n\nimpl Display {\n    pub fn empty() -> Self {\n        Display {\n            number: false,\n            title: false,\n            artist: false,\n            album: false,\n            cover: false,\n            popularity: false,\n        }\n    }\n}\n\npub fn playable_widget(track: &Track, display: Display) -> impl Widget<PlayRow<Arc<Track>>> {\n    let mut main_row = Flex::row();\n    let mut major = Flex::row().cross_axis_alignment(CrossAxisAlignment::Center);\n    let mut minor = Flex::row();\n\n    if display.number {\n        let track_number = Label::<Arc<Track>>::dynamic(|track, _| track.track_number.to_string())\n            .with_text_size(theme::TEXT_SIZE_SMALL)\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .with_text_alignment(TextAlignment::Center)\n            .center()\n            .fix_width(theme::grid(2.0))\n            .lens(PlayRow::item);\n        major.add_child(track_number);\n        major.add_default_spacer();\n\n        // Align the bottom line content.\n        minor.add_spacer(theme::grid(2.0));\n        minor.add_default_spacer();\n    }\n\n    if display.cover {\n        let album_cover = rounded_cover_widget(theme::grid(4.0))\n            .padding_right(theme::grid(1.0))\n            .lens(PlayRow::item);\n        main_row.add_child(Either::new(\n            |row, _| row.ctx.show_track_cover,\n            album_cover,\n            Empty,\n        ));\n    }\n\n    if display.title {\n        let track_name = Label::raw()\n            .with_font(theme::UI_FONT_MEDIUM)\n            .with_line_break_mode(LineBreaking::Clip)\n            .lens(PlayRow::item.then(Track::name.in_arc()))\n            .padding_right(theme::grid(1.0));\n        let is_playing = playable::is_playing_marker_widget().lens(PlayRow::is_playing);\n        major.add_flex_child(FillBetween::new(track_name, is_playing), 1.0);\n    }\n\n    let mut minor_row = Flex::row();\n    if track.explicit && (display.artist || display.album) {\n        let icon = icons::EXPLICIT.scale(theme::ICON_SIZE_TINY);\n        minor_row.add_child(icon);\n        minor_row.add_spacer(theme::grid(0.5));\n    }\n    let minor_label = Label::dynamic(move |row: &PlayRow<Arc<Track>>, _| {\n        let artist = if display.artist {\n            row.item.artist_names()\n        } else {\n            String::new()\n        };\n        let album = if display.album {\n            let mut album_name = String::new();\n            Track::lens_album_name().with(&row.item, |name| album_name = name.to_string());\n            album_name\n        } else {\n            String::new()\n        };\n        if !artist.is_empty() && !album.is_empty() {\n            format!(\"{artist} • {album}\")\n        } else {\n            format!(\"{artist}{album}\")\n        }\n    })\n    .with_line_break_mode(LineBreaking::Clip)\n    .with_text_size(theme::TEXT_SIZE_SMALL)\n    .with_text_color(theme::PLACEHOLDER_COLOR);\n\n    minor_row.add_flex_child(minor_label, 1.0);\n    minor.add_flex_child(minor_row, 1.0);\n\n    if display.popularity {\n        let track_popularity = Label::<Arc<Track>>::dynamic(|track, _| {\n            track.popularity.map(popularity_stars).unwrap_or_default()\n        })\n        .with_text_size(theme::TEXT_SIZE_SMALL)\n        .with_text_color(theme::PLACEHOLDER_COLOR)\n        .lens(PlayRow::item);\n        major.add_default_spacer();\n        major.add_child(track_popularity);\n    }\n\n    let track_duration =\n        Label::<Arc<Track>>::dynamic(|track, _| utils::as_minutes_and_seconds(track.duration))\n            .with_text_size(theme::TEXT_SIZE_SMALL)\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .lens(PlayRow::item);\n    major.add_default_spacer();\n    major.add_child(track_duration);\n\n    let saved = ViewSwitcher::new(\n        |row: &PlayRow<Arc<Track>>, _| row.ctx.library.saved_tracks.is_resolved(),\n        |selector: &bool, _, _| match selector {\n            true => ViewSwitcher::new(\n                |row: &PlayRow<Arc<Track>>, _| row.ctx.library.contains_track(&row.item),\n                |selector: &bool, _, _| {\n                    match selector {\n                        true => &icons::CIRCLE_CHECK,\n                        false => &icons::CIRCLE_PLUS,\n                    }\n                    .scale(theme::ICON_SIZE_SMALL)\n                    .boxed()\n                },\n            )\n            .on_left_click(|ctx, _, row, _| {\n                let track = &row.item;\n                if row.ctx.library.contains_track(track) {\n                    ctx.submit_command(library::UNSAVE_TRACK.with(track.id))\n                } else {\n                    ctx.submit_command(library::SAVE_TRACK.with(track.clone()))\n                }\n            })\n            .boxed(),\n            false => Box::new(Flex::column()),\n        },\n    );\n\n    main_row\n        .with_flex_child(\n            Flex::column()\n                .cross_axis_alignment(CrossAxisAlignment::Start)\n                .with_child(major)\n                .with_spacer(2.0)\n                .with_child(minor)\n                .on_left_click(|ctx, _, row, _| {\n                    ctx.submit_notification(cmd::PLAY.with(row.position))\n                }),\n            1.0,\n        )\n        .with_default_spacer()\n        .with_child(saved.center())\n        .padding(theme::grid(1.0))\n        .link()\n        .active(|row: &PlayRow<Arc<Track>>, _env: &Env| {\n            // Check if this track is the target of album detail navigation\n            if let Nav::AlbumDetail(_, Some(target_id)) = &row.ctx.nav {\n                return *target_id == row.item.id;\n            }\n            // Otherwise check if it's playing or is the current track\n            row.is_playing || row.ctx.now_playing.as_ref().is_some_and(|playable| {\n                matches!(playable, Playable::Track(track) if track.id == row.item.id)\n            })\n        })\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .context_menu(track_row_menu)\n}\n\nfn cover_widget(size: f64) -> impl Widget<Arc<Track>> {\n    RemoteImage::new(placeholder_widget(), move |track: &Arc<Track>, _| {\n        track\n            .album\n            .as_ref()\n            .and_then(|al| al.image(size, size).map(|image| image.url.clone()))\n    })\n    .fix_size(size, size)\n}\n\nfn rounded_cover_widget(size: f64) -> impl Widget<Arc<Track>> {\n    cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0))\n}\n\nfn popularity_stars(popularity: u32) -> String {\n    const COUNT: usize = 5;\n\n    let popularity_coef = popularity as f32 / 100.0;\n    let popular = (COUNT as f32 * popularity_coef).round() as usize;\n    let unpopular = COUNT - popular;\n\n    let mut stars = String::with_capacity(COUNT);\n    for _ in 0..popular {\n        stars.push('★');\n    }\n    for _ in 0..unpopular {\n        stars.push('☆');\n    }\n    stars\n}\n\nfn track_row_menu(row: &PlayRow<Arc<Track>>) -> Menu<AppState> {\n    track_menu(&row.item, &row.ctx.library, &row.origin, row.item.track_pos)\n}\n\npub fn track_menu(\n    track: &Arc<Track>,\n    library: &Library,\n    origin: &PlaybackOrigin,\n    track_pos: usize,\n) -> Menu<AppState> {\n    let mut menu = Menu::empty();\n\n    for artist_link in &track.artists {\n        let more_than_one_artist = track.artists.len() > 1;\n        let title = if more_than_one_artist {\n            LocalizedString::new(\"menu-item-show-artist-name\")\n                .with_placeholder(format!(\"Go to Artist \\\"{}\\\"\", artist_link.name))\n        } else {\n            LocalizedString::new(\"menu-item-show-artist\").with_placeholder(\"Go to Artist\")\n        };\n        menu = menu.entry(\n            MenuItem::new(title)\n                .command(cmd::NAVIGATE.with(Nav::ArtistDetail(artist_link.to_owned()))),\n        );\n    }\n\n    if let Some(album_link) = track.album.as_ref() {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-show-album\").with_placeholder(\"Go to Album\"),\n            )\n            .command(cmd::NAVIGATE.with(Nav::AlbumDetail(album_link.to_owned(), None))),\n        );\n    }\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-show-recommended\")\n                .with_placeholder(\"Show Similar Tracks\"),\n        )\n        .command(cmd::NAVIGATE.with(Nav::Recommendations(Arc::new(\n            RecommendationsRequest::for_track(track.id),\n        )))),\n    );\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-show-credits\").with_placeholder(\"Show Track Credits\"),\n        )\n        .command(cmd::SHOW_CREDITS_WINDOW.with(track.clone())),\n    );\n\n    menu = menu.separator();\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-copy-link\").with_placeholder(\"Copy Link to Track\"),\n        )\n        .command(cmd::COPY.with(track.url())),\n    );\n\n    if library.contains_track(track) {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-remove-from-library\")\n                    .with_placeholder(\"Remove Track from Library\"),\n            )\n            .command(library::UNSAVE_TRACK.with(track.id)),\n        );\n    } else {\n        menu = menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-save-to-library\")\n                    .with_placeholder(\"Save Track to Library\"),\n            )\n            .command(library::SAVE_TRACK.with(track.clone())),\n        );\n    }\n\n    if let PlaybackOrigin::Playlist(playlist) = origin {\n        // Do some (hopefully) quick checks to determine if we should give the\n        // option to remove items from this playlist, only allowing it if the\n        // playlist is collaborative or we are the owner of it\n        let should_show = {\n            if let Some(details) = library\n                .playlists\n                .resolved()\n                .and_then(|pl| pl.iter().find(|p| p.id == playlist.id))\n            {\n                if details.collaborative {\n                    true\n                } else if let Some(user) = library.user_profile.resolved() {\n                    user.id == details.owner.id\n                } else {\n                    // If we can find the playlist, but for some reason can't\n                    // resolve our own user, just show the option anyways and\n                    // we'll see an error at the bottom if it doesn't work\n                    // when they try to remove a track\n                    true\n                }\n            } else {\n                // If this playlist doesn't exist in our library,\n                // just assume that we can't edit it since we probably\n                // searched for it or something\n                false\n            }\n        };\n\n        if should_show {\n            menu = menu.entry(\n                MenuItem::new(\n                    LocalizedString::new(\"menu-item-remove-from-playlist\")\n                        .with_placeholder(\"Remove from Current Playlist\"),\n                )\n                .command(playlist::REMOVE_TRACK.with(PlaylistRemoveTrack {\n                    link: playlist.to_owned(),\n                    track_pos,\n                })),\n            );\n        }\n    }\n\n    menu = menu.entry(\n        MenuItem::new(\n            LocalizedString::new(\"menu-item-add-to-queue\").with_placeholder(\"Add Track to Queue\"),\n        )\n        // PlayerCommand\n        .command(cmd::ADD_TO_QUEUE.with((\n            QueueEntry {\n                item: crate::ui::Playable::Track(track.clone()),\n                origin: origin.clone(),\n            },\n            PlaybackItem {\n                item_id: ItemId::from_base62(&String::from(track.id), ItemIdType::Track).unwrap(),\n                norm_level: NormalizationLevel::Track,\n            },\n        ))),\n    );\n\n    let mut playlist_menu = Menu::new(\n        LocalizedString::new(\"menu-item-add-to-playlist\").with_placeholder(\"Add to Playlist\"),\n    );\n    for playlist in library.writable_playlists() {\n        playlist_menu = playlist_menu.entry(\n            MenuItem::new(\n                LocalizedString::new(\"menu-item-save-to-playlist\")\n                    .with_placeholder(format!(\"{}\", playlist.name)),\n            )\n            .command(playlist::ADD_TRACK.with(PlaylistAddTrack {\n                link: playlist.link(),\n                track_id: track.id,\n            })),\n        );\n    }\n    menu = menu.entry(playlist_menu);\n\n    menu\n}\n"
  },
  {
    "path": "psst-gui/src/ui/user.rs",
    "content": "use druid::{\n    commands,\n    widget::{Either, Flex, Label},\n    Data, LensExt, Selector, Widget, WidgetExt,\n};\n\nuse crate::{\n    data::{AppState, Library, UserProfile},\n    webapi::WebApi,\n    widget::{icons, icons::SvgIcon, Async, Empty, MyWidgetExt},\n};\n\nuse super::theme;\n\npub const LOAD_PROFILE: Selector = Selector::new(\"app.user.load-profile\");\n\npub fn user_widget() -> impl Widget<AppState> {\n    let is_connected = Either::new(\n        // TODO: Avoid the locking here.\n        |state: &AppState, _| state.session.is_connected(),\n        Label::new(\"Connected\")\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .with_text_size(theme::TEXT_SIZE_SMALL),\n        Label::new(\"Disconnected\")\n            .with_text_color(theme::PLACEHOLDER_COLOR)\n            .with_text_size(theme::TEXT_SIZE_SMALL),\n    );\n\n    let user_profile = Async::new(\n        || Empty,\n        || {\n            Label::raw()\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .lens(UserProfile::display_name)\n        },\n        || Empty,\n    )\n    .lens(AppState::library.then(Library::user_profile.in_arc()))\n    .on_command_async(\n        LOAD_PROFILE,\n        |_| WebApi::global().get_user_profile(),\n        |_, data, d| data.with_library_mut(|l| l.user_profile.defer(d)),\n        |_, data, r| data.with_library_mut(|l| l.user_profile.update(r)),\n    );\n\n    Flex::row()\n        .with_child(\n            Flex::column()\n                .with_child(is_connected)\n                .with_default_spacer()\n                .with_child(user_profile)\n                .padding(theme::grid(1.0)),\n        )\n        .with_child(preferences_widget(&icons::PREFERENCES))\n}\n\nfn preferences_widget<T: Data>(svg: &SvgIcon) -> impl Widget<T> {\n    svg.scale((theme::grid(3.0), theme::grid(3.0)))\n        .padding(theme::grid(1.0))\n        .link()\n        .rounded(theme::BUTTON_BORDER_RADIUS)\n        .on_left_click(|ctx, _, _, _| ctx.submit_command(commands::SHOW_PREFERENCES))\n}\n"
  },
  {
    "path": "psst-gui/src/ui/utils.rs",
    "content": "use std::{f64::consts::PI, time::Duration};\n\nuse druid::{\n    kurbo::Circle,\n    widget::{prelude::*, CrossAxisAlignment, Flex, Label, SizedBox},\n    Data, Point, Vec2, Widget, WidgetExt, WidgetPod,\n};\nuse time_humanize::HumanTime;\n\nuse crate::{data::WithCtx, error::Error, widget::icons};\n\nuse super::theme;\n\nstruct Spinner {\n    t: f64,\n}\n\nimpl Spinner {\n    pub fn new() -> Self {\n        Self { t: 0.0 }\n    }\n}\n\nimpl<T: Data> Widget<T> for Spinner {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, _data: &mut T, _env: &Env) {\n        if let Event::AnimFrame(interval) = event {\n            self.t += (*interval as f64) * 1e-9;\n            if self.t >= 1.0 {\n                self.t = 0.0;\n            }\n            ctx.request_anim_frame();\n            ctx.request_paint();\n        }\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, _data: &T, _env: &Env) {\n        if let LifeCycle::WidgetAdded = event {\n            ctx.request_anim_frame();\n            ctx.request_paint();\n        }\n    }\n\n    fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {}\n\n    fn layout(\n        &mut self,\n        _layout_ctx: &mut LayoutCtx,\n        bc: &BoxConstraints,\n        _data: &T,\n        _env: &Env,\n    ) -> Size {\n        bc.constrain(Size::new(theme::grid(6.0), theme::grid(16.0)))\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, env: &Env) {\n        let center = ctx.size().to_rect().center();\n        let c0 = env.get(theme::GREY_500);\n        let c1 = env.get(theme::GREY_400);\n        let active = 7 - (1 + (6.0 * self.t).floor() as i32);\n        for i in 1..=6 {\n            let step = f64::from(i);\n            let angle = Vec2::from_angle((step / 6.0) * -2.0 * PI);\n            let dot_center = center + angle * theme::grid(2.0);\n            let dot = Circle::new(dot_center, theme::grid(0.8));\n            if i == active {\n                ctx.fill(dot, &c1);\n            } else {\n                ctx.fill(dot, &c0);\n            }\n        }\n    }\n}\n\npub fn stat_row<T: Data>(\n    label: &'static str,\n    value_func: impl Fn(&T) -> String + 'static,\n) -> impl Widget<WithCtx<T>> {\n    Flex::row()\n        .with_child(\n            Label::new(label)\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .with_text_color(theme::PLACEHOLDER_COLOR),\n        )\n        .with_spacer(theme::grid(0.5))\n        .with_child(\n            Label::new(move |ctx: &WithCtx<T>, _env: &_| value_func(&ctx.data))\n                .with_text_size(theme::TEXT_SIZE_SMALL),\n        )\n        .align_left()\n}\n\npub fn placeholder_widget<T: Data>() -> impl Widget<T> {\n    SizedBox::empty().background(theme::BACKGROUND_DARK)\n}\n\npub fn spinner_widget<T: Data>() -> impl Widget<T> {\n    Spinner::new().center()\n}\n\npub fn error_widget() -> impl Widget<Error> {\n    let icon = icons::ERROR\n        .scale((theme::grid(3.0), theme::grid(3.0)))\n        .with_color(theme::PLACEHOLDER_COLOR);\n    let error = Flex::column()\n        .cross_axis_alignment(CrossAxisAlignment::Start)\n        .with_child(\n            Label::new(\"Error:\")\n                .with_font(theme::UI_FONT_MEDIUM)\n                .with_text_color(theme::PLACEHOLDER_COLOR),\n        )\n        .with_child(\n            Label::dynamic(|err: &Error, _| err.to_string())\n                .with_text_size(theme::TEXT_SIZE_SMALL)\n                .with_text_color(theme::PLACEHOLDER_COLOR),\n        );\n    Flex::row()\n        .with_child(icon)\n        .with_default_spacer()\n        .with_child(error)\n        .padding((0.0, theme::grid(6.0)))\n        .center()\n}\n\npub fn as_minutes_and_seconds(dur: Duration) -> String {\n    let minutes = dur.as_secs() / 60;\n    let seconds = dur.as_secs() % 60;\n    format!(\"{minutes}∶{seconds:02}\")\n}\n\npub fn as_human(dur: Duration) -> String {\n    HumanTime::from(dur).to_text_en(\n        time_humanize::Accuracy::Rough,\n        time_humanize::Tense::Present,\n    )\n}\n\npub fn format_number_with_commas(n: i64) -> String {\n    let s = n.to_string();\n    if s.len() <= 3 {\n        return s;\n    }\n    // Reverse the string, chunk it, then reverse the chunks to process from left to right.\n    s.chars()\n        .rev()\n        .collect::<Vec<_>>()\n        .chunks(3)\n        .rev()\n        // Reverse the characters in each chunk back to their original order and collect into a string.\n        .map(|chunk| chunk.iter().rev().collect::<String>())\n        .collect::<Vec<_>>()\n        // Join the chunks with commas.\n        .join(\",\")\n}\n\npub struct InfoLayout<T, B, S> {\n    biography: WidgetPod<T, B>,\n    stats: WidgetPod<T, S>,\n}\n\nimpl<T, B, S> InfoLayout<T, B, S>\nwhere\n    T: Data,\n    B: Widget<T>,\n    S: Widget<T>,\n{\n    pub fn new(biography: B, stats: S) -> Self {\n        Self {\n            biography: WidgetPod::new(biography),\n            stats: WidgetPod::new(stats),\n        }\n    }\n}\n\nimpl<T: Data, B: Widget<T>, S: Widget<T>> Widget<T> for InfoLayout<T, B, S> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.biography.event(ctx, event, data, env);\n        self.stats.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.biography.lifecycle(ctx, event, data, env);\n        self.stats.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        self.biography.update(ctx, data, env);\n        self.stats.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let max = bc.max();\n        let wide_layout = max.width > theme::grid(60.0) + theme::GRID * 3.45;\n        let padding = theme::grid(1.0);\n        let image_height = theme::grid(16.0);\n\n        if wide_layout {\n            // In wide layout, the biography is left of the stats.\n            // The biography's height is constrained to the image height.\n            let biography_width = max.width * 0.67 - padding / 2.0;\n            let stats_width = max.width * 0.33 - padding / 2.0;\n\n            let biography_bc =\n                BoxConstraints::new(Size::ZERO, Size::new(biography_width, image_height));\n            let stats_bc = BoxConstraints::new(Size::ZERO, Size::new(stats_width, max.height));\n\n            let biography_size = self.biography.layout(ctx, &biography_bc, data, env);\n            let stats_size = self.stats.layout(ctx, &stats_bc, data, env);\n\n            self.biography.set_origin(ctx, Point::ORIGIN);\n            self.stats\n                .set_origin(ctx, Point::new(biography_width + padding, 0.0));\n\n            Size::new(max.width, biography_size.height.max(stats_size.height))\n        } else {\n            // In narrow view, the biography and stats are stacked vertically, and\n            // their combined height should be equal to the image height.\n            let stats_bc = BoxConstraints::new(Size::ZERO, Size::new(max.width, max.height));\n            let stats_size = self.stats.layout(ctx, &stats_bc, data, env);\n\n            let biography_height = (image_height - stats_size.height - padding).max(0.0);\n            let biography_bc = BoxConstraints::tight(Size::new(max.width, biography_height));\n            let biography_size = self.biography.layout(ctx, &biography_bc, data, env);\n\n            self.biography.set_origin(ctx, Point::ORIGIN);\n            self.stats\n                .set_origin(ctx, Point::new(0.0, biography_size.height + padding));\n\n            Size::new(max.width, image_height)\n        }\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        self.biography.paint(ctx, data, env);\n        self.stats.paint(ctx, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/webapi/cache.rs",
    "content": "use std::{\n    collections::hash_map::DefaultHasher,\n    fs::{self, File},\n    hash::{Hash, Hasher},\n    num::NonZeroUsize,\n    path::PathBuf,\n    sync::Arc,\n};\n\nuse druid::image;\nuse druid::ImageBuf;\nuse lru::LruCache;\nuse parking_lot::Mutex;\nuse psst_core::cache::mkdir_if_not_exists;\n\npub struct WebApiCache {\n    base: Option<PathBuf>,\n    images: Mutex<LruCache<Arc<str>, ImageBuf>>,\n}\n\nimpl WebApiCache {\n    pub fn new(base: Option<PathBuf>) -> Self {\n        const IMAGE_CACHE_SIZE: usize = 256;\n        Self {\n            base,\n            images: Mutex::new(LruCache::new(NonZeroUsize::new(IMAGE_CACHE_SIZE).unwrap())),\n        }\n    }\n\n    pub fn get_image(&self, uri: &Arc<str>) -> Option<ImageBuf> {\n        self.images.lock().get(uri).cloned()\n    }\n\n    pub fn set_image(&self, uri: Arc<str>, image: ImageBuf) {\n        self.images.lock().put(uri, image);\n    }\n\n    pub fn get_image_from_disk(&self, uri: &Arc<str>) -> Option<ImageBuf> {\n        let hash = Self::hash_uri(uri);\n        self.key(\"images\", &format!(\"{hash:016x}\"))\n            .and_then(|path| std::fs::read(path).ok())\n            .and_then(|bytes| image::load_from_memory(&bytes).ok())\n            .map(ImageBuf::from_dynamic_image)\n    }\n\n    pub fn save_image_to_disk(&self, uri: &Arc<str>, data: &[u8]) {\n        let hash = Self::hash_uri(uri);\n        if let Some(path) = self.key(\"images\", &format!(\"{hash:016x}\")) {\n            if let Some(parent) = path.parent() {\n                let _ = std::fs::create_dir_all(parent);\n            }\n            let _ = std::fs::write(path, data);\n        }\n    }\n\n    fn hash_uri(uri: &str) -> u64 {\n        let mut hasher = DefaultHasher::new();\n        uri.hash(&mut hasher);\n        hasher.finish()\n    }\n\n    pub fn get(&self, bucket: &str, key: &str) -> Option<File> {\n        self.key(bucket, key).and_then(|path| File::open(path).ok())\n    }\n\n    pub fn set(&self, bucket: &str, key: &str, value: &[u8]) {\n        if let Some(path) = self.bucket(bucket) {\n            if let Err(err) = mkdir_if_not_exists(&path) {\n                log::error!(\"failed to create WebAPI cache bucket: {err:?}\");\n            }\n        }\n        if let Some(path) = self.key(bucket, key) {\n            if let Err(err) = fs::write(path, value) {\n                log::error!(\"failed to save to WebAPI cache: {err:?}\");\n            }\n        }\n    }\n\n    fn bucket(&self, bucket: &str) -> Option<PathBuf> {\n        self.base.as_ref().map(|path| path.join(bucket))\n    }\n\n    fn key(&self, bucket: &str, key: &str) -> Option<PathBuf> {\n        self.bucket(bucket).map(|path| path.join(key))\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/webapi/client.rs",
    "content": "use std::{\n    collections::HashMap,\n    fmt::Display,\n    io::{self, Read},\n    path::PathBuf,\n    sync::Arc,\n    thread,\n    time::Duration,\n};\n\nuse druid::{\n    im::Vector,\n    image::{self, ImageFormat},\n    Data, ImageBuf,\n};\n\nuse itertools::Itertools;\nuse log::info;\nuse parking_lot::Mutex;\nuse psst_core::{\n    session::{login5::Login5, SessionService},\n    system_info::{OS, SPOTIFY_SEMANTIC_VERSION},\n};\nuse serde::{de::DeserializeOwned, Deserialize};\nuse serde_json::json;\nuse std::sync::OnceLock;\nuse ureq::{\n    http::{Response, StatusCode},\n    Agent, Body,\n};\n\nuse crate::{\n    data::{\n        self, utils::sanitize_html_string, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo,\n        ArtistLink, ArtistStats, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, Image,\n        MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, RecommendationsRequest,\n        SearchResults, SearchTopic, Show, SpotifyUrl, Track, TrackLines, UserProfile,\n    },\n    error::Error,\n    ui::credits::TrackCredits,\n};\n\nuse super::{cache::WebApiCache, local::LocalTrackManager};\nuse sanitize_html::{rules::predefined::DEFAULT, sanitize_str};\n\npub struct WebApi {\n    session: SessionService,\n    agent: Agent,\n    cache: WebApiCache,\n    login5: Login5,\n    local_track_manager: Mutex<LocalTrackManager>,\n    paginated_limit: usize,\n}\n\nimpl WebApi {\n    pub fn new(\n        session: SessionService,\n        proxy_url: Option<&str>,\n        cache_base: Option<PathBuf>,\n        paginated_limit: usize,\n    ) -> Self {\n        let mut agent = Agent::config_builder().timeout_global(Some(Duration::from_secs(5)));\n        if let Some(proxy_url) = proxy_url {\n            let proxy = ureq::Proxy::new(proxy_url).ok();\n            agent = agent.proxy(proxy);\n        }\n        Self {\n            session,\n            agent: agent.build().into(),\n            cache: WebApiCache::new(cache_base),\n            login5: Login5::new(None, proxy_url),\n            local_track_manager: Mutex::new(LocalTrackManager::new()),\n            paginated_limit,\n        }\n    }\n\n    // Similar to how librespot does this https://github.com/librespot-org/librespot/blob/dev/core/src/version.rs\n    fn user_agent() -> String {\n        let platform = match OS {\n            \"macos\" => \"OSX\",\n            \"windows\" => \"Win32\",\n            _ => \"Linux\",\n        };\n        format!(\n            \"Spotify/{} {}/0 (psst/{})\",\n            SPOTIFY_SEMANTIC_VERSION,\n            platform,\n            env!(\"CARGO_PKG_VERSION\")\n        )\n    }\n\n    fn access_token(&self) -> Result<String, Error> {\n        self.login5\n            .get_access_token(&self.session)\n            .map_err(|err| Error::WebApiError(err.to_string()))\n            .map(|t| t.access_token)\n    }\n\n    fn request(&self, request: &RequestBuilder) -> Result<Response<Body>, Error> {\n        let token = self.access_token()?;\n        let url = request.build();\n\n        fn configure_request<B>(\n            req_builder: ureq::RequestBuilder<B>,\n            token: &str,\n            headers: &HashMap<String, String>,\n        ) -> ureq::RequestBuilder<B> {\n            headers.iter().fold(\n                req_builder.header(\"Authorization\", &format!(\"Bearer {token}\")),\n                |current_req, (k, v)| current_req.header(k, v),\n            )\n        }\n\n        match request.get_method() {\n            Method::Get => configure_request(self.agent.get(&url), &token, request.get_headers())\n                .call()\n                .map_err(|err| Error::WebApiError(err.to_string())),\n            Method::Post => configure_request(self.agent.post(&url), &token, request.get_headers())\n                .send_json(request.get_body())\n                .map_err(|err| Error::WebApiError(err.to_string())),\n            Method::Put => configure_request(self.agent.put(&url), &token, request.get_headers())\n                .send_json(request.get_body())\n                .map_err(|err| Error::WebApiError(err.to_string())),\n            Method::Delete => {\n                configure_request(self.agent.delete(&url), &token, request.get_headers())\n                    .force_send_body()\n                    .send_json(request.get_body())\n                    .map_err(|err| Error::WebApiError(err.to_string()))\n            }\n        }\n    }\n\n    fn with_retry(f: impl Fn() -> Result<Response<Body>, Error>) -> Result<Response<Body>, Error> {\n        loop {\n            let response = f()?;\n            match response.status() {\n                StatusCode::TOO_MANY_REQUESTS => {\n                    let retry_after_secs = response\n                        .headers()\n                        .get(\"Retry-After\")\n                        .and_then(|secs| secs.to_str().ok());\n                    let secs = retry_after_secs.unwrap_or(\"2\").parse::<u64>().unwrap_or(2);\n                    thread::sleep(Duration::from_secs(secs));\n                }\n                _ => {\n                    break Ok(response);\n                }\n            }\n        }\n    }\n\n    /// Send a request with an empty JSON object, throw away the response body.\n    /// Use for POST/PUT/DELETE requests.\n    fn send_empty_json(&self, request: &RequestBuilder) -> Result<(), Error> {\n        Self::with_retry(|| self.request(request)).map(|_| ())\n    }\n\n    /// Send a request and return the deserialized JSON body.  Use for GET\n    /// requests.\n    fn load<T: DeserializeOwned>(&self, request: &RequestBuilder) -> Result<T, Error> {\n        let mut response = Self::with_retry(|| self.request(request))?;\n        response\n            .body_mut()\n            .read_json()\n            .map_err(|err| Error::WebApiError(err.to_string()))\n    }\n\n    /// Send a request using `self.load()`, but only if it isn't already present\n    /// in cache.\n    fn load_cached<T: Data + DeserializeOwned>(\n        &self,\n        request: &RequestBuilder,\n        bucket: &str,\n        key: &str,\n    ) -> Result<Cached<T>, Error> {\n        if let Some(file) = self.cache.get(bucket, key) {\n            let cached_at = file.metadata()?.modified()?;\n            let value = serde_json::from_reader(file)?;\n            Ok(Cached::new(value, cached_at))\n        } else {\n            let response = Self::with_retry(|| self.request(request))?;\n            let body = {\n                let mut reader = response.into_body().into_reader();\n                let mut body = Vec::new();\n                reader.read_to_end(&mut body)?;\n                body\n            };\n            let value = serde_json::from_slice(&body)?;\n            self.cache.set(bucket, key, &body);\n            Ok(Cached::fresh(value))\n        }\n    }\n\n    /// Iterate a paginated result set by sending `request` with added\n    /// pagination parameters.  Mostly used through `load_all_pages`.\n    fn for_all_pages<T: DeserializeOwned + Clone>(\n        &self,\n        request: &RequestBuilder,\n        mut func: impl FnMut(Page<T>) -> Result<(), Error>,\n    ) -> Result<(), Error> {\n        // TODO: Some result sets, like very long playlists and saved tracks/albums can\n        // be very big.  Implement virtualized scrolling and lazy-loading of results.\n        let mut limit = 50;\n        let mut offset = 0;\n        loop {\n            let req = request\n                .clone()\n                .query(\"limit\".to_string(), limit.to_string())\n                .query(\"offset\".to_string(), offset.to_string());\n            let page: Page<T> = self.load(&req)?;\n\n            let page_total = page.total;\n            let page_offset = page.offset;\n            let page_limit = page.limit;\n            func(page)?;\n\n            if page_total > offset && offset < self.paginated_limit {\n                limit = page_limit;\n                offset = page_offset + page_limit;\n            } else {\n                break Ok(());\n            }\n        }\n    }\n\n    /// Very similar to `for_all_pages`, but only returns a certain number of results\n    fn for_some_pages<T: DeserializeOwned + Clone>(\n        &self,\n        request: &RequestBuilder,\n        lim: usize,\n        mut func: impl FnMut(Page<T>) -> Result<(), Error>,\n    ) -> Result<(), Error> {\n        let mut limit = 50;\n        let mut offset = 0;\n        if lim < limit {\n            limit = lim;\n            let req = request\n                .clone()\n                .query(\"limit\".to_string(), limit.to_string())\n                .query(\"offset\".to_string(), offset.to_string());\n\n            let page: Page<T> = self.load(&req)?;\n\n            func(page)?;\n        } else {\n            loop {\n                let req = request\n                    .clone()\n                    .query(\"limit\".to_string(), limit.to_string())\n                    .query(\"offset\".to_string(), offset.to_string());\n\n                let page: Page<T> = self.load(&req)?;\n\n                let page_total = limit / lim;\n                let page_offset = page.offset;\n                let page_limit = page.limit;\n                func(page)?;\n\n                if page_total > offset && offset < self.paginated_limit {\n                    limit = page_limit;\n                    offset = page_offset + page_limit;\n                } else {\n                    break;\n                }\n            }\n        }\n        Ok(())\n    }\n    /// Load a paginated result set by sending `request` with added pagination\n    /// parameters and return the aggregated results.  Use with GET requests.\n    fn load_all_pages<T: DeserializeOwned + Clone>(\n        &self,\n        request: &RequestBuilder,\n    ) -> Result<Vector<T>, Error> {\n        let mut results = Vector::new();\n\n        self.for_all_pages(request, |page| {\n            results.append(page.items);\n            Ok(())\n        })?;\n\n        Ok(results)\n    }\n\n    /// Does a similar thing as `load_all_pages`, but limiting the number of results\n    fn load_some_pages<T: DeserializeOwned + Clone>(\n        &self,\n        request: &RequestBuilder,\n        number: usize,\n    ) -> Result<Vector<T>, Error> {\n        let mut results = Vector::new();\n\n        self.for_some_pages(request, number, |page| {\n            results.append(page.items);\n            Ok(())\n        })?;\n\n        Ok(results)\n    }\n\n    /// Load local track files from the official client's database.\n    pub fn load_local_tracks(&self, username: &str) {\n        if let Err(err) = self\n            .local_track_manager\n            .lock()\n            .load_tracks_for_user(username)\n        {\n            log::error!(\"failed to read local tracks: {err}\");\n        }\n    }\n\n    fn load_and_return_home_section(&self, request: &RequestBuilder) -> Result<MixedView, Error> {\n        #[derive(Deserialize)]\n        pub struct Welcome {\n            data: WelcomeData,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct WelcomeData {\n            home_sections: HomeSections,\n        }\n\n        #[derive(Deserialize)]\n        pub struct HomeSections {\n            sections: Vec<Section>,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Section {\n            data: SectionData,\n            section_items: SectionItems,\n        }\n\n        #[derive(Deserialize)]\n        pub struct SectionData {\n            title: Title,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Title {\n            text: String,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct SectionItems {\n            items: Vec<Item>,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Item {\n            content: Content,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Content {\n            data: ContentData,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct ContentData {\n            #[serde(rename = \"__typename\")]\n            typename: DataTypename,\n            name: Option<String>,\n            uri: Option<String>,\n\n            // Playlist-specific fields\n            attributes: Option<Vec<Attribute>>,\n            description: Option<String>,\n            images: Option<Images>,\n            owner_v2: Option<OwnerV2>,\n\n            // Artist-specific fields\n            artists: Option<Artists>,\n            profile: Option<Profile>,\n            visuals: Option<Visuals>,\n\n            // Show-specific fields\n            cover_art: Option<CoverArt>,\n            publisher: Option<Publisher>,\n            total_episodes: Option<usize>,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Visuals {\n            avatar_image: CoverArt,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Artists {\n            items: Vec<ArtistsItem>,\n        }\n\n        #[derive(Deserialize)]\n        pub struct ArtistsItem {\n            profile: Profile,\n            uri: String,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Profile {\n            name: String,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Attribute {\n            key: String,\n            value: String,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct CoverArt {\n            sources: Vec<Source>,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Source {\n            url: String,\n        }\n\n        #[derive(Deserialize)]\n        #[allow(dead_code)]\n        pub enum MediaType {\n            #[serde(rename = \"AUDIO\")]\n            Audio,\n            #[serde(rename = \"MIXED\")]\n            Mixed,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Publisher {\n            name: String,\n        }\n\n        #[derive(Deserialize)]\n        pub enum DataTypename {\n            Podcast,\n            Playlist,\n            Artist,\n            Album,\n            NotFound,\n        }\n\n        #[derive(Deserialize)]\n        pub struct Images {\n            items: Vec<ImagesItem>,\n        }\n\n        #[derive(Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct ImagesItem {\n            sources: Vec<Source>,\n        }\n\n        #[derive(Deserialize)]\n        pub struct OwnerV2 {\n            data: OwnerV2Data,\n        }\n\n        #[derive(Deserialize)]\n        pub struct OwnerV2Data {\n            #[serde(rename = \"__typename\")]\n            name: String,\n        }\n\n        // Extract the playlists\n        let result: Welcome = match self.load(request) {\n            Ok(res) => res,\n            Err(e) => {\n                info!(\"Error loading home section: {e}\");\n                return Err(e);\n            }\n        };\n\n        let mut title: Arc<str> = Arc::from(\"\");\n        let mut playlist: Vector<Playlist> = Vector::new();\n        let mut album: Vector<Arc<Album>> = Vector::new();\n        let mut artist: Vector<Artist> = Vector::new();\n        let mut show: Vector<Arc<Show>> = Vector::new();\n\n        result\n            .data\n            .home_sections\n            .sections\n            .iter()\n            .for_each(|section| {\n                title = section.data.title.text.clone().into();\n\n                section.section_items.items.iter().for_each(|item| {\n                    let Some(uri) = &item.content.data.uri else {\n                        return;\n                    };\n                    let id = uri.split(':').next_back().unwrap_or(\"\").to_string();\n\n                    match item.content.data.typename {\n                        DataTypename::Playlist => {\n                            playlist.push_back(Playlist {\n                                id: id.into(),\n                                name: Arc::from(item.content.data.name.clone().unwrap()),\n                                images: Some(item.content.data.images.as_ref().map_or_else(\n                                    Vector::new,\n                                    |images| {\n                                        images\n                                            .items\n                                            .iter()\n                                            .map(|img| data::utils::Image {\n                                                url: Arc::from(\n                                                    img.sources\n                                                        .first()\n                                                        .map(|s| s.url.as_str())\n                                                        .unwrap_or_default(),\n                                                ),\n                                                width: None,\n                                                height: None,\n                                            })\n                                            .collect()\n                                    },\n                                )),\n                                description: {\n                                    let desc = sanitize_html_string(\n                                        item.content\n                                            .data\n                                            .description\n                                            .as_deref()\n                                            .unwrap_or_default(),\n                                    );\n\n                                    // This is roughly 3 lines of description, truncated if too long\n                                    if desc.chars().count() > 55 {\n                                        Arc::from(desc.chars().take(52).collect::<String>() + \"...\")\n                                    } else {\n                                        desc\n                                    }\n                                },\n                                track_count: item.content.data.attributes.as_ref().and_then(\n                                    |attrs| {\n                                        attrs\n                                            .iter()\n                                            .find(|attr| attr.key == \"track_count\")\n                                            .and_then(|attr| attr.value.parse().ok())\n                                    },\n                                ),\n                                owner: PublicUser {\n                                    id: Arc::from(\"\"),\n                                    display_name: Arc::from(\n                                        item.content\n                                            .data\n                                            .owner_v2\n                                            .as_ref()\n                                            .map(|owner| owner.data.name.as_str())\n                                            .unwrap_or_default(),\n                                    ),\n                                },\n                                collaborative: false,\n                                public: None,\n                            });\n                        }\n                        DataTypename::Artist => artist.push_back(Artist {\n                            id: id.into(),\n                            name: Arc::from(\n                                item.content.data.profile.as_ref().unwrap().name.clone(),\n                            ),\n                            images: item.content.data.visuals.as_ref().map_or_else(\n                                Vector::new,\n                                |images| {\n                                    images\n                                        .avatar_image\n                                        .sources\n                                        .iter()\n                                        .map(|img| data::utils::Image {\n                                            url: Arc::from(img.url.as_str()),\n                                            width: None,\n                                            height: None,\n                                        })\n                                        .collect()\n                                },\n                            ),\n                        }),\n                        DataTypename::Album => album.push_back(Arc::new(Album {\n                            id: id.into(),\n                            name: Arc::from(item.content.data.name.clone().unwrap()),\n                            album_type: AlbumType::Album,\n                            images: item.content.data.cover_art.as_ref().map_or_else(\n                                Vector::new,\n                                |images| {\n                                    images\n                                        .sources\n                                        .iter()\n                                        .map(|src| data::utils::Image {\n                                            url: Arc::from(src.url.clone()),\n                                            width: None,\n                                            height: None,\n                                        })\n                                        .collect()\n                                },\n                            ),\n                            artists: item.content.data.artists.as_ref().map_or_else(\n                                Vector::new,\n                                |artists| {\n                                    artists\n                                        .items\n                                        .iter()\n                                        .map(|artist| ArtistLink {\n                                            id: Arc::from(\n                                                artist\n                                                    .uri\n                                                    .split(':')\n                                                    .next_back()\n                                                    .unwrap_or(\"\")\n                                                    .to_string(),\n                                            ),\n                                            name: Arc::from(artist.profile.name.clone()),\n                                        })\n                                        .collect()\n                                },\n                            ),\n                            copyrights: Vector::new(),\n                            label: \"\".into(),\n                            tracks: Vector::new(),\n                            release_date: None,\n                            release_date_precision: None,\n                        })),\n                        DataTypename::Podcast => show.push_back(Arc::new(Show {\n                            id: id.into(),\n                            name: Arc::from(item.content.data.name.clone().unwrap()),\n                            images: item.content.data.cover_art.as_ref().map_or_else(\n                                Vector::new,\n                                |images| {\n                                    images\n                                        .sources\n                                        .iter()\n                                        .map(|src| data::utils::Image {\n                                            url: Arc::from(src.url.clone()),\n                                            width: None,\n                                            height: None,\n                                        })\n                                        .collect()\n                                },\n                            ),\n                            publisher: Arc::from(\n                                item.content\n                                    .data\n                                    .publisher\n                                    .as_ref()\n                                    .map(|p| p.name.as_str())\n                                    .unwrap_or(\"\"),\n                            ),\n                            description: Arc::from(\n                                item.content.data.description.as_deref().unwrap_or(\"\"),\n                            ),\n                            total_episodes: item.content.data.total_episodes,\n                        })),\n                        // For section items we don't cover yet\n                        DataTypename::NotFound => {}\n                    }\n                });\n            });\n\n        Ok(MixedView {\n            title,\n            playlists: playlist,\n            artists: artist,\n            albums: album,\n            shows: show,\n        })\n    }\n}\n\nstatic GLOBAL_WEBAPI: OnceLock<Arc<WebApi>> = OnceLock::new();\n\n/// Global instance.\nimpl WebApi {\n    pub fn install_as_global(self) {\n        GLOBAL_WEBAPI\n            .set(Arc::new(self))\n            .map_err(|_| \"Cannot install more than once\")\n            .unwrap()\n    }\n\n    pub fn global() -> Arc<Self> {\n        GLOBAL_WEBAPI.get().unwrap().clone()\n    }\n}\n\n/// User endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-users-profile\n    pub fn get_user_profile(&self) -> Result<UserProfile, Error> {\n        let result = self.load(&RequestBuilder::new(\"v1/me\".to_string(), Method::Get, None))?;\n        Ok(result)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-users-top-artists-and-tracks\n    pub fn get_user_top_tracks(&self) -> Result<Vector<Arc<Track>>, Error> {\n        let request = &RequestBuilder::new(\"v1/me/top/tracks\".to_string(), Method::Get, None)\n            .query(\"market\", \"from_token\");\n        let result: Vector<Arc<Track>> = self.load_some_pages(request, 30)?;\n\n        Ok(result)\n    }\n\n    pub fn get_user_top_artist(&self) -> Result<Vector<Artist>, Error> {\n        #[derive(Clone, Data, Deserialize)]\n        #[allow(dead_code)]\n        struct Artists {\n            artists: Artist,\n        }\n        let request = &RequestBuilder::new(\"v1/me/top/artists\", Method::Get, None);\n\n        Ok(self\n            .load_some_pages(request, 10)?\n            .into_iter()\n            .map(|item: Artist| item)\n            .collect())\n    }\n}\n\n/// Artist endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-artist/\n    pub fn get_artist(&self, id: &str) -> Result<Artist, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/artists/{id}\"), Method::Get, None);\n        let result = self.load_cached(request, \"artist\", id)?;\n        Ok(result.data)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-albums/\n    pub fn get_artist_albums(&self, id: &str) -> Result<ArtistAlbums, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/artists/{id}/albums\"), Method::Get, None)\n            .query(\"market\", \"from_token\");\n        let result: Vector<Arc<Album>> = self.load_all_pages(request)?;\n\n        let mut artist_albums = ArtistAlbums {\n            albums: Vector::new(),\n            singles: Vector::new(),\n            compilations: Vector::new(),\n            appears_on: Vector::new(),\n        };\n\n        let mut last_album_release_year = usize::MAX;\n        let mut last_single_release_year = usize::MAX;\n\n        for album in result {\n            match album.album_type {\n                // Spotify is labeling albums and singles that should be labeled `appears_on` as `album` or `single`.\n                // They are still ordered properly though, with the most recent first, then 'appears_on'.\n                // So we just wait until they are no longer descending, then start putting them in the 'appears_on' Vec.\n                // NOTE: This will break if an artist has released 'appears_on' albums/singles before their first actual album/single.\n                AlbumType::Album => {\n                    if album.release_year_int() > last_album_release_year {\n                        artist_albums.appears_on.push_back(album)\n                    } else {\n                        last_album_release_year = album.release_year_int();\n                        artist_albums.albums.push_back(album)\n                    }\n                }\n                AlbumType::Single => {\n                    if album.release_year_int() > last_single_release_year {\n                        artist_albums.appears_on.push_back(album);\n                    } else {\n                        last_single_release_year = album.release_year_int();\n                        artist_albums.singles.push_back(album);\n                    }\n                }\n                AlbumType::Compilation => artist_albums.compilations.push_back(album),\n                AlbumType::AppearsOn => artist_albums.appears_on.push_back(album),\n            }\n        }\n        Ok(artist_albums)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-top-tracks\n    pub fn get_artist_top_tracks(&self, id: &str) -> Result<Vector<Arc<Track>>, Error> {\n        #[derive(Deserialize)]\n        struct Tracks {\n            tracks: Vector<Arc<Track>>,\n        }\n        let request =\n            &RequestBuilder::new(format!(\"v1/artists/{id}/top-tracks\"), Method::Get, None)\n                .query(\"market\", \"from_token\");\n        let result: Tracks = self.load(request)?;\n        Ok(result.tracks)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-related-artists\n    pub fn get_related_artists(&self, id: &str) -> Result<Cached<Vector<Artist>>, Error> {\n        #[derive(Clone, Data, Deserialize)]\n        struct Artists {\n            artists: Vector<Artist>,\n        }\n        let request = &RequestBuilder::new(\n            format!(\"v1/artists/{id}/related-artists\"),\n            Method::Get,\n            None,\n        );\n        let result: Cached<Artists> = self.load_cached(request, \"related-artists\", id)?;\n        Ok(result.map(|result| result.artists))\n    }\n\n    pub fn get_artist_info(&self, id: &str) -> Result<ArtistInfo, Error> {\n        #[derive(Clone, Data, Deserialize)]\n        pub struct Welcome {\n            data: Data1,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Data1 {\n            artist_union: ArtistUnion,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        pub struct ArtistUnion {\n            profile: Profile,\n            stats: Stats,\n            visuals: Visuals,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Profile {\n            biography: Biography,\n            external_links: ExternalLinks,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        pub struct Biography {\n            text: String,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        pub struct ExternalLinks {\n            items: Vector<ExternalLinksItem>,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Visuals {\n            avatar_image: AvatarImage,\n        }\n        #[derive(Clone, Data, Deserialize)]\n        pub struct AvatarImage {\n            sources: Vector<Image>,\n        }\n        #[derive(Clone, Data, Deserialize)]\n        pub struct ExternalLinksItem {\n            url: String,\n        }\n\n        #[derive(Clone, Data, Deserialize)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Stats {\n            followers: i64,\n            monthly_listeners: i64,\n            world_rank: i64,\n        }\n\n        let variables = json!( {\n            \"locale\": \"\",\n            \"uri\": format!(\"spotify:artist:{}\", id),\n        });\n        let json = json!({\n            \"extensions\": {\n                \"persistedQuery\": {\n                    \"version\": 1,\n                    \"sha256Hash\": \"1ac33ddab5d39a3a9c27802774e6d78b9405cc188c6f75aed007df2a32737c72\"\n                }\n            },\n            \"operationName\": \"queryArtistOverview\",\n            \"variables\": variables,\n        });\n\n        let request =\n            &RequestBuilder::new(\"pathfinder/v2/query\".to_string(), Method::Post, Some(json))\n                .set_base_uri(\"api-partner.spotify.com\")\n                .header(\"User-Agent\", Self::user_agent());\n        let result: Cached<Welcome> = self.load_cached(request, \"artist-info\", id)?;\n\n        let hrefs: Vector<String> = result\n            .data\n            .data\n            .artist_union\n            .profile\n            .external_links\n            .items\n            .into_iter()\n            .map(|link| link.url)\n            .collect();\n\n        Ok(ArtistInfo {\n            main_image: Arc::from(\n                result.data.data.artist_union.visuals.avatar_image.sources[0]\n                    .url\n                    .to_string(),\n            ),\n            stats: ArtistStats {\n                followers: result.data.data.artist_union.stats.followers,\n                monthly_listeners: result.data.data.artist_union.stats.monthly_listeners,\n                world_rank: result.data.data.artist_union.stats.world_rank,\n            },\n            bio: {\n                let sanitized_bio = sanitize_str(\n                    &DEFAULT,\n                    &result.data.data.artist_union.profile.biography.text,\n                )\n                .unwrap_or_default();\n                sanitized_bio.replace(\"&amp;\", \"&\")\n            },\n\n            artist_links: hrefs,\n        })\n    }\n}\n\n/// Album endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-an-album/\n    pub fn get_album(&self, id: &str) -> Result<Cached<Arc<Album>>, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/albums/{id}\"), Method::Get, None)\n            .query(\"market\", \"from_token\");\n        let result = self.load_cached(request, \"album\", id)?;\n        Ok(result)\n    }\n}\n\n/// Show endpoints. (Podcasts)\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-a-show/Add commentMore actions\n    pub fn get_show(&self, id: &str) -> Result<Cached<Arc<Show>>, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/shows/{id}\"), Method::Get, None)\n            .query(\"market\", \"from_token\");\n\n        let result = self.load_cached(request, \"show\", id)?;\n\n        Ok(result)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-multiple-episodes\n    pub fn get_episodes(\n        &self,\n        ids: impl IntoIterator<Item = EpisodeId>,\n    ) -> Result<Vector<Arc<Episode>>, Error> {\n        #[derive(Deserialize)]\n        struct Episodes {\n            episodes: Vector<Arc<Episode>>,\n        }\n\n        let request = &RequestBuilder::new(\"v1/episodes\", Method::Get, None)\n            .query(\"ids\", ids.into_iter().map(|id| id.0.to_base62()).join(\",\"))\n            .query(\"market\", \"from_token\");\n        let result: Episodes = self.load(request)?;\n        Ok(result.episodes)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-a-shows-episodes\n    pub fn get_show_episodes(&self, id: &str) -> Result<Vector<Arc<Episode>>, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/shows/{id}/episodes\"), Method::Get, None)\n            .query(\"market\", \"from_token\");\n\n        let mut results = Vector::new();\n        self.for_all_pages(request, |page: Page<Option<EpisodeLink>>| {\n            if !page.items.is_empty() {\n                let ids = page\n                    .items\n                    .into_iter()\n                    .filter_map(|link| link.map(|link| link.id));\n                let episodes = self.get_episodes(ids)?;\n                results.append(episodes);\n            }\n            Ok(())\n        })?;\n\n        Ok(results)\n    }\n}\n\n/// Track endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-track\n    pub fn get_track(&self, id: &str) -> Result<Arc<Track>, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/tracks/{id}\"), Method::Get, None)\n            .query(\"market\", \"from_token\");\n        self.load(request)\n    }\n\n    pub fn get_track_credits(&self, track_id: &str) -> Result<TrackCredits, Error> {\n        let request = &RequestBuilder::new(\n            format!(\"track-credits-view/v0/experimental/{track_id}/credits\"),\n            Method::Get,\n            None,\n        )\n        .set_base_uri(\"spclient.wg.spotify.com\");\n        let result: TrackCredits = self.load(request)?;\n        Ok(result)\n    }\n\n    pub fn get_lyrics(&self, track_id: String) -> Result<Vector<TrackLines>, Error> {\n        #[derive(Default, Debug, Clone, PartialEq, Deserialize, Data)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Root {\n            pub lyrics: Lyrics,\n        }\n\n        #[derive(Default, Debug, Clone, PartialEq, Deserialize, Data)]\n        #[serde(rename_all = \"camelCase\")]\n        pub struct Lyrics {\n            pub lines: Vector<TrackLines>,\n            pub provider: String,\n            pub provider_lyrics_id: String,\n        }\n\n        let request = &RequestBuilder::new(\n            format!(\"color-lyrics/v2/track/{track_id}\"),\n            Method::Get,\n            None,\n        )\n        .set_base_uri(\"spclient.wg.spotify.com\")\n        .query(\"format\", \"json\")\n        .query(\"vocalRemoval\", \"false\")\n        .query(\"market\", \"from_token\")\n        .header(\"app-platform\", \"WebPlayer\");\n\n        let lyrics: Cached<Root> = self.load_cached(request, \"lyrics\", &track_id)?;\n        Ok(lyrics.data.lyrics.lines)\n    }\n}\n\n/// Library endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-users-saved-albums/\n    pub fn get_saved_albums(&self) -> Result<Vector<Arc<Album>>, Error> {\n        #[derive(Clone, Deserialize)]\n        struct SavedAlbum {\n            album: Arc<Album>,\n        }\n\n        let request =\n            &RequestBuilder::new(\"v1/me/albums\", Method::Get, None).query(\"market\", \"from_token\");\n\n        Ok(self\n            .load_all_pages(request)?\n            .into_iter()\n            .map(|item: SavedAlbum| item.album)\n            .collect())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/save-albums-user/\n    pub fn save_album(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/albums\", Method::Put, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/remove-albums-user/\n    pub fn unsave_album(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/albums\", Method::Delete, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-users-saved-tracks/\n    pub fn get_saved_tracks(&self) -> Result<Vector<Arc<Track>>, Error> {\n        #[derive(Clone, Deserialize)]\n        struct SavedTrack {\n            track: Arc<Track>,\n        }\n        let request =\n            &RequestBuilder::new(\"v1/me/tracks\", Method::Get, None).query(\"market\", \"from_token\");\n        Ok(self\n            .load_all_pages(request)?\n            .into_iter()\n            .map(|item: SavedTrack| item.track)\n            .collect())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-users-saved-shows\n    pub fn get_saved_shows(&self) -> Result<Vector<Arc<Show>>, Error> {\n        #[derive(Clone, Deserialize)]\n        struct SavedShow {\n            show: Arc<Show>,\n        }\n\n        let request =\n            &RequestBuilder::new(\"v1/me/shows\", Method::Get, None).query(\"market\", \"from_token\");\n\n        Ok(self\n            .load_all_pages(request)?\n            .into_iter()\n            .map(|item: SavedShow| item.show)\n            .collect())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/save-tracks-user/\n    pub fn save_track(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/tracks\", Method::Put, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/remove-tracks-user/\n    pub fn unsave_track(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/tracks\", Method::Delete, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/save-shows-user\n    pub fn save_show(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/shows\", Method::Put, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/remove-shows-user\n    pub fn unsave_show(&self, id: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\"v1/me/shows\", Method::Delete, None).query(\"ids\", id);\n        self.send_empty_json(request)\n    }\n}\n\n/// View endpoints.\nimpl WebApi {\n    pub fn get_user_info(&self) -> Result<(String, String), Error> {\n        #[derive(Deserialize, Clone, Data)]\n        struct User {\n            region: String,\n            timezone: String,\n        }\n        let token = self.access_token()?;\n\n        let request = &RequestBuilder::new(\"json\".to_string(), Method::Get, None)\n            .set_protocol(\"http\")\n            .set_base_uri(\"ip-api.com\")\n            .query(\"fields\", \"260\")\n            .header(\"Authorization\", format!(\"Bearer {token}\"));\n\n        let result: Cached<User> = self.load_cached(request, \"user-info\", \"usrinfo\")?;\n\n        Ok((result.data.region, result.data.timezone))\n    }\n\n    pub fn get_section(&self, section_uri: &str) -> Result<MixedView, Error> {\n        let (country, time_zone) = self.get_user_info()?;\n        let access_token = self.access_token()?;\n\n        let json = json!({\n            \"extensions\": {\n                \"persistedQuery\": {\n                    \"version\": 1,\n                    \"sha256Hash\": \"eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be\"\n                }\n            },\n            \"operationName\": \"homeSection\",\n            \"variables\":  {\n                \"sectionItemsLimit\": 20,\n                \"sectionItemsOffset\": 0,\n                \"sp_t\": access_token,\n                \"timeZone\": time_zone,\n                \"country\": country,\n                \"uri\": section_uri\n            },\n        });\n\n        let request =\n            &RequestBuilder::new(\"pathfinder/v2/query\".to_string(), Method::Post, Some(json))\n                .set_base_uri(\"api-partner.spotify.com\")\n                .header(\"User-Agent\", Self::user_agent());\n\n        // Extract the playlists\n        self.load_and_return_home_section(request)\n    }\n\n    pub fn get_made_for_you(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAUnp4wcj0bCb3wh3S -> Made for you\n        self.get_section(\"spotify:section:0JQ5DAUnp4wcj0bCb3wh3S\")\n    }\n\n    pub fn get_top_mixes(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAnM3wGh0gz1MXnu89 -> Top mixes\n        self.get_section(\"spotify:section:0JQ5DAnM3wGh0gz1MXnu89\")\n    }\n\n    pub fn recommended_stations(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAnM3wGh0gz1MXnu3R -> Recommended stations\n        self.get_section(\"spotify:section:0JQ5DAnM3wGh0gz1MXnu3R\")\n    }\n\n    pub fn uniquely_yours(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAUnp4wcj0bCb3wh3S -> Uniquely yours\n        self.get_section(\"spotify:section:0JQ5DAUnp4wcj0bCb3wh3S\")\n    }\n\n    pub fn best_of_artists(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAnM3wGh0gz1MXnu3n -> Best of artists\n        self.get_section(\"spotify:section:0JQ5DAnM3wGh0gz1MXnu3n\")\n    }\n\n    // Need to make a mix of it!\n    pub fn jump_back_in(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAIiKWzVFULQfUm85X -> Jump back in\n        self.get_section(\"spotify:section:0JQ5DAIiKWzVFULQfUm85X\")\n    }\n\n    // Shows\n    pub fn your_shows(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAnM3wGh0gz1MXnu3N -> Your shows\n        self.get_section(\"spotify:section:0JQ5DAnM3wGh0gz1MXnu3N\")\n    }\n\n    pub fn shows_that_you_might_like(&self) -> Result<MixedView, Error> {\n        // 0JQ5DAnM3wGh0gz1MXnu3P -> Shows that you might like\n        self.get_section(\"spotify:section:0JQ5DAnM3wGh0gz1MXnu3P\")\n    }\n}\n\n/// Playlist endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-a-list-of-current-users-playlists\n    pub fn get_playlists(&self) -> Result<Vector<Playlist>, Error> {\n        let request = &RequestBuilder::new(\"v1/me/playlists\", Method::Get, None);\n        let result: Vector<Playlist> = self.load_all_pages(request)?;\n        Ok(result)\n    }\n\n    pub fn follow_playlist(&self, id: &str) -> Result<(), Error> {\n        let request =\n            &RequestBuilder::new(format!(\"v1/playlists/{id}/followers\"), Method::Put, None)\n                .set_body(Some(json!({\"public\": false})));\n        self.request(request)?;\n        Ok(())\n    }\n\n    pub fn unfollow_playlist(&self, id: &str) -> Result<(), Error> {\n        let request =\n            &RequestBuilder::new(format!(\"v1/playlists/{id}/followers\"), Method::Delete, None);\n        self.request(request)?;\n        Ok(())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-playlist\n    pub fn get_playlist(&self, id: &str) -> Result<Playlist, Error> {\n        let request = &RequestBuilder::new(format!(\"v1/playlists/{id}\"), Method::Get, None);\n        let result: Playlist = self.load(request)?;\n        Ok(result)\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/get-playlists-tracks\n    pub fn get_playlist_tracks(&self, id: &str) -> Result<Vector<Arc<Track>>, Error> {\n        #[derive(Clone, Deserialize)]\n        struct PlaylistItem {\n            track: OptionalTrack,\n        }\n\n        // Spotify API likes to return _really_ bogus data for local tracks. Much better\n        // would be to ignore parsing this completely if `is_local` is true, but this\n        // will do as well.\n        #[derive(Clone, Deserialize)]\n        #[serde(untagged)]\n        enum OptionalTrack {\n            Track(Arc<Track>),\n            Json(serde_json::Value),\n        }\n\n        let request = &RequestBuilder::new(format!(\"v1/playlists/{id}/tracks\"), Method::Get, None)\n            .query(\"marker\", \"from_token\")\n            .query(\"additional_types\", \"track\");\n\n        let result: Vector<PlaylistItem> = self.load_all_pages(request)?;\n\n        let local_track_manager = self.local_track_manager.lock();\n\n        Ok(result\n            .into_iter()\n            .enumerate()\n            .filter_map(|(index, item)| {\n                let mut track = match item.track {\n                    OptionalTrack::Track(track) => track,\n                    OptionalTrack::Json(json) => local_track_manager.find_local_track(json)?,\n                };\n                Arc::make_mut(&mut track).track_pos = index;\n                Some(track)\n            })\n            .collect())\n    }\n\n    pub fn change_playlist_details(&self, id: &str, name: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(format!(\"v1/playlists/{id}/tracks\"), Method::Get, None)\n            .set_body(Some(json!({ \"name\": name })));\n        self.request(request)?;\n        Ok(())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/add-tracks-to-playlist\n    pub fn add_track_to_playlist(&self, playlist_id: &str, track_uri: &str) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\n            format!(\"v1/playlists/{playlist_id}/tracks\"),\n            Method::Post,\n            None,\n        )\n        .query(\"uris\", track_uri);\n        self.request(request).map(|_| ())\n    }\n\n    // https://developer.spotify.com/documentation/web-api/reference/remove-tracks-playlist\n    pub fn remove_track_from_playlist(\n        &self,\n        playlist_id: &str,\n        track_pos: usize,\n    ) -> Result<(), Error> {\n        let request = &RequestBuilder::new(\n            format!(\"v1/playlists/{playlist_id}/tracks\"),\n            Method::Delete,\n            None,\n        )\n        .set_body(Some(json!({ \"positions\": [track_pos] })));\n        self.request(request).map(|_| ())\n    }\n}\n\n/// Search endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/search/\n    pub fn search(\n        &self,\n        query: &str,\n        topics: &[SearchTopic],\n        limit: usize,\n    ) -> Result<SearchResults, Error> {\n        #[derive(Deserialize)]\n        struct ApiSearchResults {\n            artists: Option<Page<Artist>>,\n            albums: Option<Page<Arc<Album>>>,\n            tracks: Option<Page<Arc<Track>>>,\n            playlists: Option<Page<Playlist>>,\n            shows: Option<Page<Arc<Show>>>,\n        }\n\n        let encoded_query = urlencoding::encode(query);\n        let type_query_param = topics.iter().map(SearchTopic::as_str).join(\",\");\n        let request = &RequestBuilder::new(\"v1/search\", Method::Get, None)\n            .query(\"q\", encoded_query)\n            .query(\"type\", &type_query_param)\n            .query(\"limit\", limit.to_string())\n            .query(\"marker\", \"from_token\");\n\n        let result: ApiSearchResults = self.load(request)?;\n\n        let artists = result.artists.map_or_else(Vector::new, |page| page.items);\n        let albums = result.albums.map_or_else(Vector::new, |page| page.items);\n        let tracks = result.tracks.map_or_else(Vector::new, |page| page.items);\n        let playlists = result.playlists.map_or_else(Vector::new, |page| page.items);\n        let shows = result.shows.map_or_else(Vector::new, |page| page.items);\n        let topic = (topics.len() == 1).then_some(topics[0]);\n\n        Ok(SearchResults {\n            query: query.into(),\n            topic,\n            artists,\n            albums,\n            tracks,\n            playlists,\n            shows,\n        })\n    }\n\n    pub fn load_spotify_link(&self, link: &SpotifyUrl) -> Result<Nav, Error> {\n        let nav = match link {\n            SpotifyUrl::Playlist(id) => Nav::PlaylistDetail(self.get_playlist(id)?.link()),\n            SpotifyUrl::Artist(id) => Nav::ArtistDetail(self.get_artist(id)?.link()),\n            SpotifyUrl::Album(id) => Nav::AlbumDetail(self.get_album(id)?.data.link(), None),\n            SpotifyUrl::Show(id) => Nav::ShowDetail(self.get_show(id)?.data.link()),\n            SpotifyUrl::Track(id) => {\n                let track = self.get_track(id)?;\n                let album = track.album.clone().ok_or_else(|| {\n                    Error::WebApiError(\"Track was found but has no album\".to_string())\n                })?;\n                Nav::AlbumDetail(album, Some(track.id))\n            }\n        };\n        Ok(nav)\n    }\n}\n\n/// Recommendation endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-recommendations\n    pub fn get_recommendations(\n        &self,\n        data: Arc<RecommendationsRequest>,\n    ) -> Result<Recommendations, Error> {\n        let seed_artists = data.seed_artists.iter().map(|link| &link.id).join(\", \");\n        let seed_tracks = data\n            .seed_tracks\n            .iter()\n            .map(|track| track.0.to_base62())\n            .join(\", \");\n\n        let mut request = RequestBuilder::new(\"v1/recommendations\", Method::Get, None)\n            .query(\"marker\", \"from_token\")\n            .query(\"limit\", \"100\")\n            .query(\"seed_artists\", &seed_artists)\n            .query(\"seed_tracks\", &seed_tracks);\n\n        fn add_range_param(\n            req: RequestBuilder,\n            r: Range<impl ToString>,\n            s: &str,\n        ) -> RequestBuilder {\n            let mut req = req;\n            if let Some(v) = r.min {\n                req = req.query(format!(\"min_{s}\"), v.to_string());\n            }\n            if let Some(v) = r.max {\n                req = req.query(format!(\"max_{s}\"), v.to_string());\n            }\n            if let Some(v) = r.target {\n                req = req.query(format!(\"target_{s}\"), v.to_string());\n            }\n            req\n        }\n\n        request = add_range_param(request, data.params.duration_ms, \"duration_ms\");\n        request = add_range_param(request, data.params.popularity, \"popularity\");\n        request = add_range_param(request, data.params.key, \"key\");\n        request = add_range_param(request, data.params.mode, \"mode\");\n        request = add_range_param(request, data.params.tempo, \"tempo\");\n        request = add_range_param(request, data.params.time_signature, \"time_signature\");\n        request = add_range_param(request, data.params.acousticness, \"acousticness\");\n        request = add_range_param(request, data.params.danceability, \"danceability\");\n        request = add_range_param(request, data.params.energy, \"energy\");\n        request = add_range_param(request, data.params.instrumentalness, \"instrumentalness\");\n        request = add_range_param(request, data.params.liveness, \"liveness\");\n        request = add_range_param(request, data.params.loudness, \"loudness\");\n        request = add_range_param(request, data.params.speechiness, \"speechiness\");\n        request = add_range_param(request, data.params.valence, \"valence\");\n\n        let mut result: Recommendations = self.load(&request)?;\n        result.request = data;\n        Ok(result)\n    }\n}\n\n/// Track endpoints.\nimpl WebApi {\n    // https://developer.spotify.com/documentation/web-api/reference/get-audio-analysis/\n    pub fn _get_audio_analysis(&self, track_id: &str) -> Result<AudioAnalysis, Error> {\n        let request =\n            &RequestBuilder::new(format!(\"v1/audio-analysis/{track_id}\"), Method::Get, None);\n        let result = self.load_cached(request, \"audio-analysis\", track_id)?;\n        Ok(result.data)\n    }\n}\n\n/// Image endpoints.\nimpl WebApi {\n    pub fn get_cached_image(&self, uri: &Arc<str>) -> Option<ImageBuf> {\n        self.cache.get_image(uri)\n    }\n\n    pub fn get_image(&self, uri: Arc<str>) -> Result<ImageBuf, Error> {\n        if let Some(cached_image) = self.cache.get_image(&uri) {\n            return Ok(cached_image);\n        }\n\n        if let Some(disk_cached_image) = self.cache.get_image_from_disk(&uri) {\n            self.cache.set_image(uri.clone(), disk_cached_image.clone());\n            return Ok(disk_cached_image);\n        }\n\n        // Split the URI into its components\n        let uri_clone = uri.clone();\n        let parsed = url::Url::parse(&uri_clone).unwrap();\n\n        let protocol = parsed.scheme();\n        let base_uri = parsed.host_str().unwrap();\n        let path = parsed.path().trim_start_matches('/');\n\n        let mut queries = std::collections::HashMap::new();\n        for (k, v) in parsed.query_pairs() {\n            queries.insert(k.to_string(), v.to_string());\n        }\n\n        let request = RequestBuilder::new(path, Method::Get, None)\n            .set_protocol(protocol)\n            .set_base_uri(base_uri);\n\n        let response = self.request(&request)?;\n        let mut body = Vec::new();\n        response.into_body().into_reader().read_to_end(&mut body)?;\n\n        let format = match infer::get(body.as_slice()) {\n            Some(kind) if kind.mime_type() == \"image/jpeg\" => Some(ImageFormat::Jpeg),\n            Some(kind) if kind.mime_type() == \"image/png\" => Some(ImageFormat::Png),\n            Some(kind) if kind.mime_type() == \"image/webp\" => Some(ImageFormat::WebP),\n            _ => None,\n        };\n\n        // Save raw image data to disk cache\n        self.cache.save_image_to_disk(&uri, &body);\n\n        let image = if let Some(format) = format {\n            image::load_from_memory_with_format(&body, format)?\n        } else {\n            image::load_from_memory(&body)?\n        };\n        let image_buf = ImageBuf::from_dynamic_image(image);\n        self.cache.set_image(uri, image_buf.clone());\n        Ok(image_buf)\n    }\n}\n\nimpl From<io::Error> for Error {\n    fn from(err: io::Error) -> Self {\n        Error::WebApiError(err.to_string())\n    }\n}\n\nimpl From<ureq::Error> for Error {\n    fn from(err: ureq::Error) -> Self {\n        Error::WebApiError(err.to_string())\n    }\n}\n\nimpl From<serde_json::Error> for Error {\n    fn from(err: serde_json::Error) -> Self {\n        Error::WebApiError(err.to_string())\n    }\n}\n\nimpl From<image::ImageError> for Error {\n    fn from(err: image::ImageError) -> Self {\n        Error::WebApiError(err.to_string())\n    }\n}\n\n#[derive(Debug, Clone)]\nenum Method {\n    Post,\n    Put,\n    Delete,\n    Get,\n}\n\n// Creating a new URI builder so aid in the creation of uris with extendable queries.\n#[derive(Debug, Clone)]\nstruct RequestBuilder {\n    protocol: String,\n    base_uri: String,\n    path: String,\n    queries: HashMap<String, String>,\n    headers: HashMap<String, String>,\n    method: Method,\n    body: Option<serde_json::Value>,\n}\n\nimpl RequestBuilder {\n    // By default, we use https and the api.spotify.com\n    fn new(path: impl Display, method: Method, body: Option<serde_json::Value>) -> Self {\n        Self {\n            protocol: \"https\".to_string(),\n            base_uri: \"api.spotify.com\".to_string(),\n            path: path.to_string(),\n            queries: HashMap::new(),\n            headers: HashMap::new(),\n            method,\n            body,\n        }\n    }\n\n    fn query(mut self, key: impl Display, value: impl Display) -> Self {\n        self.queries.insert(key.to_string(), value.to_string());\n        self\n    }\n\n    fn header(mut self, key: impl Display, value: impl Display) -> Self {\n        self.headers.insert(key.to_string(), value.to_string());\n        self\n    }\n\n    fn set_protocol(mut self, protocol: impl Display) -> Self {\n        self.protocol = protocol.to_string();\n        self\n    }\n    fn get_headers(&self) -> &HashMap<String, String> {\n        &self.headers\n    }\n    fn get_body(&self) -> Option<&serde_json::Value> {\n        self.body.as_ref()\n    }\n    fn set_body(mut self, body: Option<serde_json::Value>) -> Self {\n        self.body = body;\n        self\n    }\n    fn get_method(&self) -> &Method {\n        &self.method\n    }\n    #[allow(dead_code)]\n    fn set_method(mut self, method: Method) -> Self {\n        self.method = method;\n        self\n    }\n    fn set_base_uri(mut self, url: impl Display) -> Self {\n        self.base_uri = url.to_string();\n        self\n    }\n    fn build(&self) -> String {\n        let mut url = format!(\"{}://{}/{}\", self.protocol, self.base_uri, self.path);\n        if !self.queries.is_empty() {\n            url.push('?');\n            url.push_str(\n                &self\n                    .queries\n                    .iter()\n                    .map(|(k, v)| format!(\"{k}={v}\"))\n                    .collect::<Vec<_>>()\n                    .join(\"&\"),\n            );\n        }\n        url\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/webapi/local.rs",
    "content": "use std::{\n    collections::HashMap,\n    fs::File,\n    io::{self, Cursor, Read},\n    path::PathBuf,\n    str,\n    sync::Arc,\n    time::Duration,\n    vec::Vec,\n};\n\nuse druid::im::Vector;\nuse serde::Deserialize;\nuse serde_json::Value;\n\nuse crate::data::{config::Config, AlbumLink, ArtistLink, Image, Track, TrackId};\nuse psst_core::item_id::ItemId;\n\n/*\n * All local files registered by the Spotify file can be found in the file\n * located at: <Spotify config>/Users/<username>-user/local-files.bnk\n *\n * While this is not a complete reverse engineering of the way it is stored,\n * it suffices for now. The file appears to be saved as a custom fork of\n * Google's ProtoBuf format.\n *\n * The file starts with \"SPCO\" as the magic followed by 0x13, 0x00*4 --\n * couldn't tell what these bytes do.\n *\n * After that there is 0x11 and \"LocalFilesStorage\". Note the 0x11\n * corresponds to the strings length and I can only assume this is somehow\n * used to load the correct proto definition.\n *\n * Interestingly enough, Spotify bnk files are chunked. After the above\n * there is a little endian encoded value of the number of bytes in a chunk\n * maxing out at 0x1FE3. After exactly this many bytes another chunk size\n * will be present. This can even interrupt strings however it does not\n * include the 0x00, 0x00 that ends every file (I think).\n *\n * Following the first chunk size is 0x60, which I think is used to signify\n * an array in this ProtoBuf-like language. Doesn't matter too much. The\n * number of elements in the array then follows encoded as a varint\n * (https://developers.google.com/protocol-buffers/docs/encoding).\n *\n * The bytes 0x94 0x00 seem to be present in every file I have used, I don't\n * know what they represent.\n *\n * Then a track entry is presented as: `Title -> Artist -> Album ->\n * Local Path -> Trailer`. I have no clue what the trailer represents other\n * than directly after the `Local Path` the bytes `0x08 -> <varint encoding\n * of track length>`, not relevant so far. The title, artist, etc. are each\n * encoded in the following format `0x09(string identifier) -> <varint\n * string size> -> string`. If the varint size is zero this is a null\n * string.\n *\n * The end of a trailer for a given track is signified by 0x78, 0x04 from\n * what I can tell.\n */\n\nconst MAGIC_BYTES: &[u8] = b\"SPCO\";\nconst FILE_TYPE: &[u8] = b\"LocalFilesStorage\";\n\nconst ARRAY_SIGNATURE: u8 = 0x60;\nconst STRING_SIGNATURE: u8 = 0x09;\nconst TRAILER_END: [u8; 2] = [0x78u8, 0x04u8];\n\n#[derive(Clone, Debug)]\npub struct LocalTrack {\n    title: Arc<str>,\n    path: Arc<str>,\n    album: Arc<str>,\n    artist: Arc<str>,\n}\n\npub struct LocalTrackManager {\n    tracks: HashMap<Arc<str>, Vec<LocalTrack>>,\n}\n\nimpl LocalTrackManager {\n    pub fn new() -> Self {\n        Self {\n            tracks: HashMap::new(),\n        }\n    }\n\n    pub fn load_tracks_for_user(&mut self, username: &str) -> io::Result<()> {\n        let file_path =\n            Config::spotify_local_files_file(username).ok_or(io::ErrorKind::NotFound)?;\n        let local_file = File::open(&file_path)?;\n        let mut reader = LocalTracksReader::new(local_file)?;\n\n        log::info!(\"parsing local tracks: {file_path:?}\");\n\n        // Start reading the track array.\n        let num_tracks = reader.read_array()?;\n        if num_tracks > 0 {\n            reader.advance(2)?; // Skip `0x94 0x00`.\n        }\n\n        self.tracks.clear();\n\n        for n in 1..=num_tracks {\n            let title = reader.read_string()?;\n            let artist = reader.read_string()?;\n            let album = reader.read_string()?;\n            let path = reader.read_string()?;\n            let track = LocalTrack {\n                title: title.into(),\n                path: path.into(),\n                album: album.into(),\n                artist: artist.into(),\n            };\n            self.tracks\n                .entry(track.title.clone())\n                .or_default()\n                .push(track);\n            if reader.advance_until(&TRAILER_END).is_err() {\n                if n != num_tracks {\n                    log::warn!(\"found EOF but missing {} tracks\", num_tracks - n);\n                }\n                break;\n            }\n        }\n\n        Ok(())\n    }\n\n    pub fn find_local_track(&self, track_json: Value) -> Option<Arc<Track>> {\n        let local_track: LocalTrackJson = match serde_json::from_value(track_json) {\n            Ok(t) => t,\n            Err(e) => {\n                log::error!(\"error parsing track {e:?}\");\n                return None;\n            }\n        };\n\n        let matching_tracks = self.tracks.get(&local_track.name)?;\n\n        for parsed_track in matching_tracks {\n            let path: PathBuf = (&*parsed_track.path).into();\n            if !path.exists() {\n                log::error!(\"error loading local file: Path does not exist\");\n                continue;\n            }\n\n            if Self::is_matching_in_addition_to_title(parsed_track, &local_track) {\n                return Some(Arc::new(Track {\n                    id: TrackId(ItemId::from_local(path)),\n                    name: local_track.name,\n                    album: local_track.album.map(|local_album| {\n                        AlbumLink {\n                            id: local_album.id.unwrap_or_else(|| \"null\".into()), // TODO: Invalid ID\n                            name: local_album.name,\n                            images: local_album.images,\n                        }\n                    }),\n                    artists: local_track\n                        .artists\n                        .into_iter()\n                        .map(|artist| ArtistLink {\n                            id: artist.id.unwrap_or_else(|| \"null\".into()), // TODO: Invalid ID\n                            name: artist.name,\n                        })\n                        .collect(),\n                    duration: local_track.duration,\n                    disc_number: local_track.disc_number,\n                    track_number: local_track.track_number,\n                    explicit: local_track.explicit,\n                    is_local: local_track.is_local,\n                    local_path: Some(parsed_track.path.clone()),\n                    // TODO: Change this to true once playback is supported.\n                    is_playable: Some(false),\n                    popularity: local_track.popularity,\n                    track_pos: 0,\n                    lyrics: None,\n                }));\n            }\n        }\n\n        None\n    }\n\n    fn is_matching_in_addition_to_title(t1: &LocalTrack, t2: &LocalTrackJson) -> bool {\n        // TODO: More checks on if a local track may return multiple artists from\n        // Spotify's web facing API.\n        let artist_mismatch = t2\n            .artists\n            .iter()\n            .next()\n            .is_some_and(|t2_artist| t2_artist.name != t1.artist);\n        let album_mismatch = t2\n            .album\n            .as_ref()\n            .is_some_and(|t2_album| t2_album.name != t1.album);\n        !(artist_mismatch || album_mismatch)\n    }\n}\n\n// Spotify can do some weird stuff with local track APIs so serializing with\n// `serde` requires a good amount of workarounds.  The following structs reflect\n// the ones in the `data` module, with modifications to allow for null values.\n\n#[derive(Clone, Debug, Deserialize)]\nstruct LocalAlbumLinkJson {\n    #[serde(default)]\n    pub id: Option<Arc<str>>,\n    pub name: Arc<str>,\n    #[serde(default)]\n    pub images: Vector<Image>,\n}\n\n#[derive(Clone, Debug, Deserialize)]\nstruct LocalArtistLinkJson {\n    #[serde(default)]\n    pub id: Option<Arc<str>>,\n    pub name: Arc<str>,\n}\n\n#[derive(Deserialize)]\nstruct LocalTrackJson {\n    pub name: Arc<str>,\n    #[serde(default)]\n    pub album: Option<LocalAlbumLinkJson>,\n    pub artists: Vector<LocalArtistLinkJson>,\n    #[serde(rename = \"duration_ms\")]\n    #[serde(deserialize_with = \"crate::data::utils::deserialize_millis\")]\n    pub duration: Duration,\n    pub disc_number: usize,\n    pub track_number: usize,\n    pub explicit: bool,\n    pub is_local: bool,\n    pub popularity: Option<u32>,\n}\n\nstruct LocalTracksReader {\n    chunked: ChunkedReader,\n}\n\nimpl LocalTracksReader {\n    fn new(file: File) -> io::Result<Self> {\n        Ok(Self {\n            chunked: Self::parse_file(file)?,\n        })\n    }\n\n    /// Checks if `file` is in correct format and prepares it for reading.\n    fn parse_file(mut file: File) -> io::Result<ChunkedReader> {\n        // Validate the magic.\n        let magic = read_bytes(&mut file, 4)?;\n        if magic != MAGIC_BYTES {\n            return Err(io::ErrorKind::InvalidData.into());\n        }\n        // Skip `0x13, 0x00*4`.\n        advance(&mut file, 5)?;\n        // Validate the file-type marker.\n        let file_type = read_bytes(&mut file, 18)?;\n        if file_type[0] != FILE_TYPE.len() as u8 || &file_type[1..] != FILE_TYPE {\n            return Err(io::ErrorKind::InvalidData.into());\n        }\n        Ok(ChunkedReader::new(file))\n    }\n\n    fn advance(&mut self, len: usize) -> io::Result<()> {\n        advance(&mut self.chunked, len)\n    }\n\n    fn advance_until(&mut self, bytes: &[u8]) -> io::Result<()> {\n        advance_until(&mut self.chunked, bytes)\n    }\n\n    fn read_string(&mut self) -> io::Result<String> {\n        let signature = read_u8(&mut self.chunked)?;\n        if signature != STRING_SIGNATURE {\n            return Err(io::ErrorKind::InvalidData.into());\n        }\n        let str_size = read_uvarint(&mut self.chunked)?;\n        let str_buf = read_utf8(&mut self.chunked, str_size as usize)?;\n        Ok(str_buf)\n    }\n\n    fn read_array(&mut self) -> io::Result<usize> {\n        let signature = read_u8(&mut self.chunked)?;\n        if signature != ARRAY_SIGNATURE {\n            return Err(io::ErrorKind::InvalidData.into());\n        }\n        let num_entries = read_uvarint(&mut self.chunked)? as usize;\n        Ok(num_entries)\n    }\n}\n\n/// Implements a `Read` trait over the chunked file format described above.\nstruct ChunkedReader {\n    inner: File,\n    chunk: Cursor<Vec<u8>>,\n}\n\nimpl ChunkedReader {\n    fn new(inner: File) -> Self {\n        Self {\n            inner,\n            chunk: Cursor::default(),\n        }\n    }\n\n    fn read_next_chunk(&mut self) -> io::Result<()> {\n        // Two LE bytes of chunk length.\n        let size = read_u16_le(&mut self.inner)?;\n        // Chunk content.\n        let buf = read_bytes(&mut self.inner, size as usize)?;\n        self.chunk = Cursor::new(buf);\n        Ok(())\n    }\n}\n\nimpl Read for ChunkedReader {\n    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n        loop {\n            let n = self.chunk.read(buf)?;\n            if n > 0 {\n                break Ok(n);\n            } else {\n                // `self.chunk` is empty, read the next one.  Returns `Err` on EOF.\n                self.read_next_chunk()?;\n            }\n        }\n    }\n}\n\n/// Helper, reads a byte from `f` or returns `Err`.\nfn read_u8(f: &mut impl io::Read) -> io::Result<u8> {\n    let mut buf = [0u8; 1];\n    f.read_exact(&mut buf)?;\n    Ok(buf[0])\n}\n\n/// Helper, reads little-endian `u16` or returns `Err`.\nfn read_u16_le(f: &mut impl io::Read) -> io::Result<u16> {\n    let mut buf = [0u8; 2];\n    f.read_exact(&mut buf)?;\n    Ok(u16::from_le_bytes(buf))\n}\n\n/// Helper, reads ProtoBuf-style unsigned varint from `f` or returns `Err`.\nfn read_uvarint(f: &mut impl io::Read) -> io::Result<u64> {\n    let mut shift: u64 = 0;\n    let mut ret: u64 = 0;\n\n    loop {\n        let byte = read_u8(f)?;\n        let has_msb: bool = (byte & !0b01111111) != 0;\n        ret |= ((byte & 0b01111111) as u64) << shift;\n\n        if has_msb {\n            shift += 7;\n        } else {\n            break;\n        }\n    }\n\n    Ok(ret)\n}\n\n/// Helper, reads a `Vec<u8>` of length `len` from `f` or returns `Err`.\nfn read_bytes(f: &mut impl io::Read, len: usize) -> io::Result<Vec<u8>> {\n    let mut buf = vec![0u8; len];\n    f.read_exact(&mut buf)?;\n    Ok(buf)\n}\n\n/// Helper, reads a UTF-8 string of length `len` from `f` or returns `Err`.\nfn read_utf8(f: &mut impl io::Read, len: usize) -> io::Result<String> {\n    let buf = read_bytes(f, len)?;\n    String::from_utf8(buf).map_err(|_| io::ErrorKind::InvalidData.into())\n}\n\n/// Helper, skips `len` bytes of `f` or returns `Err`.\nfn advance(f: &mut impl io::Read, len: usize) -> io::Result<()> {\n    for _ in 0..len {\n        read_u8(f)?;\n    }\n    Ok(())\n}\n\n/// Helper, skips bytes of `f` until an exact continuous `bytes` match is found,\n/// or returns `Err`.\npub fn advance_until(f: &mut impl io::Read, bytes: &[u8]) -> io::Result<()> {\n    let mut i = 0;\n    while i < bytes.len() {\n        loop {\n            let r = read_u8(f)?;\n            if r == bytes[i] {\n                i += 1; // Match, continue with the next byte of `bytes`.\n                break;\n            } else {\n                i = 0; // Mismatch, start at the beginning again.\n                if r == bytes[i] {\n                    i += 1;\n                }\n            }\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "psst-gui/src/webapi/mod.rs",
    "content": "mod cache;\nmod client;\nmod local;\n\npub use client::WebApi;\n"
  },
  {
    "path": "psst-gui/src/widget/checkbox.rs",
    "content": "use druid::{\n    kurbo::{BezPath, Size},\n    piet::{LineCap, LineJoin, LinearGradient, RenderContext, StrokeStyle, UnitPoint},\n    theme,\n    widget::{prelude::*, Label, LabelText},\n    Affine,\n};\n\npub struct Checkbox {\n    label: Label<bool>,\n}\n\nimpl Checkbox {\n    pub fn new(text: impl Into<LabelText<bool>>) -> Checkbox {\n        Checkbox {\n            label: Label::new(text),\n        }\n    }\n}\n\nimpl Widget<bool> for Checkbox {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut bool, _env: &Env) {\n        match event {\n            Event::MouseDown(_) => {\n                if !ctx.is_disabled() {\n                    ctx.set_active(true);\n                    ctx.request_paint();\n                }\n            }\n            Event::MouseUp(_) => {\n                if ctx.is_active() && !ctx.is_disabled() {\n                    if ctx.is_hot() {\n                        *data = !*data;\n                    }\n                    ctx.request_paint();\n                }\n                ctx.set_active(false);\n            }\n            _ => (),\n        }\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &bool, env: &Env) {\n        self.label.lifecycle(ctx, event, data, env);\n        if matches!(\n            event,\n            LifeCycle::HotChanged(_) | LifeCycle::DisabledChanged(_)\n        ) {\n            ctx.request_paint();\n        }\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &bool, data: &bool, env: &Env) {\n        self.label.update(ctx, old_data, data, env);\n        ctx.request_paint();\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &bool, env: &Env) -> Size {\n        let x_padding = env.get(theme::WIDGET_CONTROL_COMPONENT_PADDING);\n        let check_size = env.get(theme::BASIC_WIDGET_HEIGHT);\n        let label_size = self.label.layout(ctx, bc, data, env);\n\n        let desired_size = Size::new(\n            check_size + x_padding + label_size.width,\n            check_size.max(label_size.height),\n        );\n        let our_size = bc.constrain(desired_size);\n        let baseline = self.label.baseline_offset() + (our_size.height - label_size.height);\n        ctx.set_baseline_offset(baseline);\n        our_size\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) {\n        let size = env.get(theme::BASIC_WIDGET_HEIGHT);\n        let x_padding = env.get(theme::WIDGET_CONTROL_COMPONENT_PADDING);\n        let border_width = 1.;\n\n        let rect = Size::new(size, size)\n            .to_rect()\n            .inset(-border_width / 2.)\n            .to_rounded_rect(2.);\n\n        // Paint the background\n        let background_gradient = LinearGradient::new(\n            UnitPoint::TOP,\n            UnitPoint::BOTTOM,\n            (\n                env.get(theme::BACKGROUND_LIGHT),\n                env.get(theme::BACKGROUND_DARK),\n            ),\n        );\n\n        ctx.fill(rect, &background_gradient);\n\n        let border_color = if ctx.is_hot() && !ctx.is_disabled() {\n            env.get(theme::BORDER_LIGHT)\n        } else {\n            env.get(theme::BORDER_DARK)\n        };\n\n        ctx.stroke(rect, &border_color, border_width);\n\n        if *data {\n            // Paint the checkmark\n            let mut path = BezPath::new();\n            path.move_to((4.0, 9.0));\n            path.line_to((8.0, 13.0));\n            path.line_to((14.0, 5.0));\n\n            let style = StrokeStyle::new()\n                .line_cap(LineCap::Round)\n                .line_join(LineJoin::Round);\n\n            let brush = if ctx.is_disabled() {\n                env.get(theme::DISABLED_TEXT_COLOR)\n            } else {\n                env.get(theme::TEXT_COLOR)\n            };\n\n            ctx.with_save(|ctx| {\n                ctx.transform(Affine::scale(size / 18.0));\n                ctx.stroke_styled(path, &brush, 2., &style);\n            })\n        }\n\n        // Paint the text label\n        self.label.draw_at(ctx, (size + x_padding, 0.0));\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/dispatcher.rs",
    "content": "use std::{collections::HashMap, hash::Hash};\n\nuse druid::{widget::prelude::*, Data, Point, WidgetPod};\n\ntype ChildPicker<T, U> = dyn Fn(&T, &Env) -> U;\ntype ChildBuilder<T, U> = dyn Fn(&U, &T, &Env) -> Box<dyn Widget<T>>;\n\npub struct ViewDispatcher<T, U> {\n    child_picker: Box<ChildPicker<T, U>>,\n    child_builder: Box<ChildBuilder<T, U>>,\n    children: HashMap<U, WidgetPod<T, Box<dyn Widget<T>>>>,\n    active_child_id: Option<U>,\n}\n\nimpl<T: Data, U: Data + Eq + Hash> ViewDispatcher<T, U> {\n    pub fn new(\n        child_picker: impl Fn(&T, &Env) -> U + 'static,\n        child_builder: impl Fn(&U, &T, &Env) -> Box<dyn Widget<T>> + 'static,\n    ) -> Self {\n        Self {\n            child_picker: Box::new(child_picker),\n            child_builder: Box::new(child_builder),\n            children: HashMap::new(),\n            active_child_id: None,\n        }\n    }\n\n    fn active_child(&mut self) -> Option<&mut WidgetPod<T, Box<dyn Widget<T>>>> {\n        if let Some(id) = self.active_child_id.as_ref() {\n            self.children.get_mut(id)\n        } else {\n            None\n        }\n    }\n}\n\nimpl<T: Data, U: Data + Eq + Hash> Widget<T> for ViewDispatcher<T, U> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        if event.should_propagate_to_hidden() {\n            for (_, child) in self.children.iter_mut() {\n                child.event(ctx, event, data, env);\n            }\n        } else if let Some(child) = self.active_child() {\n            child.event(ctx, event, data, env);\n        }\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        if let LifeCycle::WidgetAdded = event {\n            let child_id = (self.child_picker)(data, env);\n            let child = (self.child_builder)(&child_id, data, env);\n            self.children\n                .insert(child_id.clone(), WidgetPod::new(child));\n            self.active_child_id = Some(child_id);\n            ctx.children_changed();\n            ctx.request_layout();\n        }\n\n        if event.should_propagate_to_hidden() {\n            for (_, child) in self.children.iter_mut() {\n                child.lifecycle(ctx, event, data, env);\n            }\n        } else if let Some(child) = self.active_child() {\n            child.lifecycle(ctx, event, data, env);\n        }\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        let child_id = (self.child_picker)(data, env);\n        let mut skip_active_child = false;\n        // Safe to unwrap because self.active_child_id should not be empty\n        if !child_id.same(self.active_child_id.as_ref().unwrap()) {\n            if !self.children.contains_key(&child_id) {\n                let child = (self.child_builder)(&child_id, data, env);\n                self.children\n                    .insert(child_id.clone(), WidgetPod::new(child));\n                skip_active_child = true;\n            }\n            self.active_child_id = Some(child_id);\n            ctx.children_changed();\n            ctx.request_layout();\n        }\n        let active_child_id = self.active_child_id.as_ref().unwrap();\n        for (id, child) in self.children.iter_mut() {\n            if skip_active_child && id == active_child_id {\n                // Because the new child has not yet been initialized, we have\n                // to skip the update after switching.\n            } else {\n                child.update(ctx, data, env);\n            }\n        }\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        match self.active_child() {\n            Some(child) => {\n                let size = child.layout(ctx, bc, data, env);\n                child.set_origin(ctx, Point::ORIGIN);\n                size\n            }\n            None => bc.max(),\n        }\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        if let Some(child) = self.active_child() {\n            child.paint_raw(ctx, data, env);\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/empty.rs",
    "content": "use druid::widget::prelude::*;\n\npub struct Empty;\n\nimpl<T> Widget<T> for Empty {\n    fn event(&mut self, _ctx: &mut EventCtx, _event: &Event, _data: &mut T, _env: &Env) {}\n    fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &T, _env: &Env) {}\n    fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {}\n    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, _env: &Env) -> Size {\n        bc.min()\n    }\n    fn paint(&mut self, _ctx: &mut PaintCtx, _data: &T, _env: &Env) {}\n}\n"
  },
  {
    "path": "psst-gui/src/widget/fill_between.rs",
    "content": "use druid::{\n    BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx,\n    Point, Size, UpdateCtx, Widget, WidgetPod,\n};\n\n/// A widget that positions two children, allowing the right child to fill the remaining space.\n/// The left child is measured first, and the right child is given the rest of the available width.\npub struct FillBetween<T: Data> {\n    left: WidgetPod<T, Box<dyn Widget<T>>>,\n    right: WidgetPod<T, Box<dyn Widget<T>>>,\n}\n\nimpl<T: Data> FillBetween<T> {\n    pub fn new(left: impl Widget<T> + 'static, right: impl Widget<T> + 'static) -> Self {\n        Self {\n            left: WidgetPod::new(Box::new(left)),\n            right: WidgetPod::new(Box::new(right)),\n        }\n    }\n}\n\nimpl<T: Data> Widget<T> for FillBetween<T> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.right.event(ctx, event, data, env);\n        self.left.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.right.lifecycle(ctx, event, data, env);\n        self.left.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        self.left.update(ctx, data, env);\n        self.right.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let max_width = bc.max().width;\n\n        // Measure the left child with our max width constraint.\n        let left_bc = BoxConstraints::new(\n            Size::new(0.0, bc.min().height),\n            Size::new(max_width, bc.max().height),\n        );\n        let left_size = self.left.layout(ctx, &left_bc, data, env);\n\n        // Layout the right child in the remaining space.\n        let right_width = (max_width - left_size.width).max(0.0);\n        let right_bc = BoxConstraints::tight(Size::new(right_width, left_size.height));\n        let right_size = self.right.layout(ctx, &right_bc, data, env);\n\n        // Vertically center children.\n        let total_height = left_size.height.max(right_size.height);\n        let left_y = (total_height - left_size.height) / 2.0;\n        let right_y = (total_height - right_size.height) / 2.0;\n\n        self.left.set_origin(ctx, Point::new(0.0, left_y));\n        self.right\n            .set_origin(ctx, Point::new(left_size.width, right_y));\n\n        Size::new(max_width, total_height)\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        self.left.paint(ctx, data, env);\n        self.right.paint(ctx, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/icons.rs",
    "content": "use crate::ui::theme;\nuse druid::{kurbo::BezPath, widget::prelude::*, Affine, Color, KeyOrValue, Size};\n\n#[allow(dead_code)]\npub static LOGO: SvgIcon = SvgIcon {\n    svg_path: \"M2.34146 0.685791L28.5825 26.9268L26.9268 28.5825L0.685791 2.34145L2.34146 0.685791Z M22.2439 11.7073V15.2195C22.2441 15.8464 22.1721 16.4712 22.0295 17.0817L23.9012 18.9512C24.3547 17.7594 24.5865 16.4947 24.5854 15.2195V11.7073H22.2439ZM15.2195 29.2683V25.691C16.6827 25.5282 18.0952 25.0587 19.3646 24.3132L17.6342 22.5841C16.3849 23.1891 15.0025 23.4672 13.6165 23.3925C12.2305 23.3178 10.886 22.8926 9.70906 22.1568C8.53207 21.421 7.56101 20.3986 6.88673 19.1853C6.21245 17.9721 5.85701 16.6076 5.85367 15.2195V11.7073H3.51221V15.2195C3.51221 20.6341 7.61708 25.1063 12.8781 25.691V29.2683H8.19513V31.6098H19.9025V29.2683H15.2195ZM19.9025 14.9539V7.02438C19.9025 3.74194 17.3312 1.17072 14.0488 1.17072C13.029 1.16706 12.0261 1.43096 11.1402 1.93606C10.2542 2.44117 9.5163 3.16981 9.00001 4.04926 M8.19513 13.1437V15.1464C8.19618 16.706 8.81282 18.2022 9.91098 19.3098C10.66 20.0884 11.6134 20.6402 12.6617 20.9017C13.71 21.1633 14.8108 21.1241 15.8378 20.7886L8.19513 13.1437Z\",\n    svg_size: Size::new(29.0, 32.0),\n    op: PaintOp::Fill,\n};\n\n// SF Pro Regular - gearshape\npub static PREFERENCES: SvgIcon = SvgIcon {\n    svg_path: \"M13.1035 23.208H14.8877C15.6172 23.208 16.1885 22.751 16.3643 22.0479L16.7158 20.5098L16.9443 20.4219L18.2891 21.2568C18.9043 21.6436 19.6338 21.5381 20.1523 21.0195L21.3828 19.7891C21.9102 19.2617 21.998 18.541 21.6113 17.9346L20.7764 16.5898L20.8643 16.3789L22.4023 16.0186C23.0967 15.8428 23.5537 15.2715 23.5537 14.542V12.8105C23.5537 12.0811 23.1055 11.5098 22.4023 11.334L20.873 10.9648L20.7852 10.7363L21.6201 9.40039C22.0068 8.79395 21.9189 8.07324 21.3916 7.53711L20.1611 6.30664C19.6514 5.78809 18.9219 5.69141 18.3066 6.06934L16.9619 6.89551L16.7158 6.80762L16.3643 5.26074C16.1885 4.55762 15.6172 4.10938 14.8877 4.10938H13.1035C12.3652 4.10938 11.7939 4.55762 11.627 5.26074L11.2754 6.80762L11.0293 6.89551L9.68457 6.06934C9.06055 5.69141 8.33984 5.78809 7.83008 6.30664L6.59082 7.53711C6.07227 8.07324 5.97559 8.79395 6.3623 9.40039L7.19727 10.7363L7.10938 10.9648L5.58887 11.334C4.88574 11.5098 4.4375 12.0811 4.4375 12.8105V14.542C4.4375 15.2715 4.89453 15.8428 5.58887 16.0186L7.12695 16.3789L7.20605 16.5898L6.37109 17.9346C5.98438 18.541 6.08105 19.2617 6.59961 19.7891L7.83887 21.0195C8.34863 21.5381 9.07812 21.6436 9.69336 21.2568L11.0381 20.4219L11.2754 20.5098L11.627 22.0479C11.7939 22.751 12.3652 23.208 13.1035 23.208ZM13.332 21.5908C13.1826 21.5908 13.1035 21.5293 13.0859 21.3975L12.5586 19.2354C12.0049 19.1035 11.4688 18.875 11.0381 18.6025L9.13965 19.7715C9.02539 19.8418 8.91992 19.833 8.81445 19.7275L7.8916 18.8047C7.78613 18.708 7.78613 18.6025 7.85645 18.4883L9.02539 16.5898C8.7793 16.168 8.55078 15.6406 8.41895 15.0869L6.24805 14.5684C6.11621 14.5508 6.0459 14.4717 6.0459 14.3223V13.0215C6.0459 12.8633 6.10742 12.8018 6.24805 12.7666L8.41016 12.2568C8.54199 11.668 8.79688 11.123 9.0166 10.7275L7.84766 8.84668C7.77734 8.72363 7.77734 8.61816 7.87402 8.5127L8.80566 7.59863C8.91113 7.50195 9.00781 7.48438 9.13965 7.56348L11.0205 8.71484C11.416 8.46875 12.0049 8.22266 12.5674 8.08203L13.0859 5.91992C13.1035 5.78809 13.1826 5.71777 13.332 5.71777H14.6592C14.8086 5.71777 14.8789 5.7793 14.9053 5.91992L15.4326 8.09082C16.0039 8.23145 16.5225 8.46875 16.9619 8.71484L18.8428 7.56348C18.9746 7.49316 19.0713 7.50195 19.1768 7.60742L20.1084 8.52148C20.2139 8.61816 20.2139 8.72363 20.1348 8.84668L18.9746 10.7275C19.1855 11.123 19.4492 11.668 19.5811 12.2568L21.7432 12.7666C21.8838 12.8018 21.9365 12.8633 21.9365 13.0215V14.3223C21.9365 14.4717 21.875 14.5508 21.7432 14.5684L19.5723 15.0869C19.4404 15.6406 19.2031 16.1768 18.957 16.5898L20.126 18.4795C20.1963 18.6025 20.1963 18.6992 20.0908 18.7959L19.168 19.7275C19.0625 19.833 18.957 19.8418 18.8428 19.7715L16.9531 18.6025C16.5137 18.875 16.0127 19.0947 15.4326 19.2354L14.9053 21.3975C14.8789 21.5293 14.8086 21.5908 14.6592 21.5908H13.332ZM14 16.9941C15.8281 16.9941 17.3311 15.4912 17.3311 13.6543C17.3311 11.835 15.8281 10.332 14 10.332C12.1631 10.332 10.6514 11.835 10.6514 13.6543C10.6514 15.4912 12.1631 16.9941 14 16.9941ZM14 15.4736C12.998 15.4736 12.1807 14.6562 12.1807 13.6543C12.1807 12.6699 13.0068 11.8525 14 11.8525C14.9756 11.8525 15.793 12.6699 15.793 13.6543C15.793 14.6475 14.9756 15.4736 14 15.4736Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - key\npub static ACCOUNT: SvgIcon = SvgIcon {\n    svg_path: \"M13.332 24.3682C13.7363 24.7197 14.29 24.7637 14.6768 24.377L17.3926 21.6611C17.7705 21.2832 17.7529 20.6943 17.3838 20.3164L16.0918 19.0244L18.0078 17.1084C18.377 16.7393 18.377 16.1416 17.999 15.7637L16.25 14.0059C18.6318 12.8193 19.9678 10.8418 19.9678 8.5127C19.9678 5.2168 17.3047 2.55371 14 2.55371C10.6865 2.55371 8.03223 5.20801 8.03223 8.5127C8.03223 10.877 9.37695 12.9951 11.5215 13.9619V22.2061C11.5215 22.5225 11.6182 22.8828 11.8906 23.1201L13.332 24.3682ZM14 22.8037L13.0508 21.8545V12.8018C11.0469 12.3623 9.61426 10.6045 9.61426 8.5127C9.61426 6.0957 11.5654 4.14453 14 4.14453C16.4346 4.14453 18.377 6.0957 18.377 8.5127C18.377 10.5869 16.9355 12.3711 14.7383 12.8545V14.7617L16.4258 16.4492L14.624 18.2158V19.8066L15.8105 20.9756L14 22.8037ZM14 8.56543C14.8613 8.56543 15.5645 7.8623 15.5645 7.00098C15.5645 6.13965 14.8613 5.43652 14 5.43652C13.1299 5.43652 12.4355 6.13086 12.4355 7.00098C12.4355 7.8623 13.1387 8.56543 14 8.56543Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - internaldrive\npub static STORAGE: SvgIcon = SvgIcon {\n    svg_path: \"M3.40039 17.2139C3.40039 19.5781 5.17578 21.3535 7.71582 21.3535H20.2842C22.8242 21.3535 24.5996 19.5781 24.5996 17.2139C24.5996 16.502 24.3975 15.8604 24.1514 15.2803L21.1279 8.19629C20.5127 6.74609 19.291 5.98145 17.6562 5.98145H10.3525C8.70898 5.98145 7.4873 6.74609 6.88086 8.19629L3.875 15.2451C3.62012 15.834 3.40039 16.4844 3.40039 17.2139ZM6.57324 13.2676L8.48926 8.5918C8.78809 7.83594 9.46484 7.44043 10.3701 7.44043H17.6299C18.5439 7.44043 19.2207 7.83594 19.5195 8.5918L21.4355 13.2676C21.084 13.1533 20.6973 13.083 20.2842 13.083H7.71582C7.30273 13.083 6.9248 13.1533 6.57324 13.2676ZM5.08789 17.2139C5.08789 15.8164 6.13379 14.7705 7.71582 14.7705H20.2842C21.8662 14.7705 22.9121 15.8164 22.9121 17.2139C22.9121 18.7607 21.8662 19.6572 20.2842 19.6572H7.71582C6.13379 19.6572 5.08789 18.6201 5.08789 17.2139ZM11.4512 18.084C11.4512 18.3828 11.6885 18.6113 11.9873 18.6113C12.2773 18.6113 12.5059 18.3828 12.5059 18.084V16.3525C12.5059 16.0625 12.2773 15.8252 11.9873 15.8252C11.6885 15.8252 11.4512 16.0625 11.4512 16.3525V18.084ZM13.4727 18.084C13.4727 18.3828 13.7012 18.6113 14 18.6113C14.29 18.6113 14.5273 18.3828 14.5273 18.084V16.3525C14.5273 16.0625 14.29 15.8252 14 15.8252C13.7012 15.8252 13.4727 16.0625 13.4727 16.3525V18.084ZM15.4854 18.084C15.4854 18.3828 15.7227 18.6113 16.0215 18.6113C16.3115 18.6113 16.5488 18.3828 16.5488 18.084V16.3525C16.5488 16.0625 16.3115 15.8252 16.0215 15.8252C15.7227 15.8252 15.4854 16.0625 15.4854 16.3525V18.084ZM17.5068 18.084C17.5068 18.3828 17.7441 18.6113 18.043 18.6113C18.333 18.6113 18.5703 18.3828 18.5703 18.084V16.3525C18.5703 16.0625 18.333 15.8252 18.043 15.8252C17.7441 15.8252 17.5068 16.0625 17.5068 16.3525V18.084ZM19.5283 18.084C19.5283 18.3828 19.7656 18.6113 20.0645 18.6113C20.3545 18.6113 20.583 18.3828 20.583 18.084V16.3525C20.583 16.0625 20.3545 15.8252 20.0645 15.8252C19.7656 15.8252 19.5283 16.0625 19.5283 16.3525V18.084Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n\npub static BACK: SvgIcon = SvgIcon {\n    svg_path: \"M9.70711 0.292893C10.0976 0.683417 10.0976 1.31658 9.70711 1.70711L2.41421 9L9.70711 16.2929C10.0976 16.6834 10.0976 17.3166 9.70711 17.7071C9.31658 18.0976 8.68342 18.0976 8.29289 17.7071L0.292893 9.70711C-0.0976311 9.31658 -0.0976311 8.68342 0.292893 8.29289L8.29289 0.292893C8.68342 -0.0976311 9.31658 -0.0976311 9.70711 0.292893Z\",\n    svg_size: Size::new(10.0, 18.0),\n    op: PaintOp::Fill,\n};\n\npub static DOWN: SvgIcon = SvgIcon {\n    svg_path: \"m -3.7071056,4.292866 c 0.390524,-0.39049 1.023687,-0.39049 1.414217,0 l 7.2928898,7.2929 7.2928998,-7.2929 c 0.3905,-0.39049 1.0237,-0.39049 1.4142,0 0.3905,0.39053 0.3905,1.02369 0,1.41422 l -7.9999898,7.999997 c -0.39053,0.390525 -1.02369,0.390525 -1.41422,0 L -3.7071056,5.707086 c -0.390524,-0.39053 -0.390524,-1.02369 0,-1.41422 z\",\n    svg_size: Size::new(10.0, 18.0),\n    op: PaintOp::Fill,\n};\n\npub static UP: SvgIcon = SvgIcon {\nsvg_path: \"m 13.707083,13.707109 c -0.390524,0.39049 -1.023687,0.39049 -1.414217,0 L 4.9999761,6.4142094 -2.2929238,13.707109 c -0.3905,0.39049 -1.0237,0.39049 -1.4142,0 -0.3905,-0.39053 -0.3905,-1.02369 0,-1.41422 L 4.2928661,4.2928924 c 0.39053,-0.390525 1.02369,-0.390525 1.41422,0 l 7.9999969,7.9999966 c 0.390524,0.39053 0.390524,1.02369 0,1.41422 z\",\nsvg_size: Size::new(10.0, 18.0),\nop: PaintOp::Fill,\n};\n\n// SF Pro Regular - play.fill\npub static PLAY: SvgIcon = SvgIcon {\n    svg_path: \"M9.3418 21.3711C9.71094 21.3711 10.0361 21.2393 10.4404 21.002L20.8203 14.999C21.5762 14.5596 21.8926 14.2168 21.8926 13.6631C21.8926 13.1094 21.5762 12.7754 20.8203 12.3271L10.4404 6.32422C10.0361 6.08691 9.71094 5.95508 9.3418 5.95508C8.62109 5.95508 8.11133 6.50879 8.11133 7.37891V19.9473C8.11133 20.8262 8.62109 21.3711 9.3418 21.3711Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - pause.fill\npub static PAUSE: SvgIcon = SvgIcon {\n    svg_path: \"M9.63184 20.9668H11.6973C12.5322 20.9668 12.9629 20.5361 12.9629 19.7012V7.60742C12.9629 6.75488 12.5322 6.35059 11.6973 6.3418H9.63184C8.79688 6.3418 8.36621 6.77246 8.36621 7.60742V19.7012C8.35742 20.5361 8.78809 20.9668 9.63184 20.9668ZM16.3115 20.9668H18.3682C19.2031 20.9668 19.6338 20.5361 19.6338 19.7012V7.60742C19.6338 6.75488 19.2031 6.3418 18.3682 6.3418H16.3115C15.4678 6.3418 15.0459 6.77246 15.0459 7.60742V19.7012C15.0459 20.5361 15.4678 20.9668 16.3115 20.9668Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - backward.end.alt.fill\npub static SKIP_BACK: SvgIcon = SvgIcon {\n    svg_path: \"M -0.449872 21.1701 L 1.63705 21.1701 C 2.43338 21.1701 2.85442 20.7491 2.85442 19.9436 L 2.85442 14.0581 C 2.95511 14.4425 3.23885 14.772 3.73313 15.0741 L 13.0602 20.5569 C 13.4355 20.7765 13.7375 20.8955 14.1128 20.8955 C 14.8359 20.8955 15.4309 20.3555 15.4309 19.3303 L 15.4309 14.0581 C 15.5316 14.4425 15.8153 14.772 16.3096 15.0741 L 25.6367 20.5569 C 26.012 20.7765 26.314 20.8955 26.6893 20.8955 C 27.4124 20.8955 28.0074 20.3555 28.0074 19.3303 L 28.0073 8.09025 C 28.0073 7.06509 27.4123 6.5159 26.6892 6.5159 C 26.314 6.5159 26.0119 6.63489 25.6366 6.85457 L 16.3096 12.3465 C 15.8153 12.6394 15.5316 12.9781 15.4309 13.3534 L 15.4309 8.09025 C 15.4309 7.06509 14.8359 6.5159 14.1128 6.5159 C 13.7376 6.5159 13.4355 6.63489 13.0602 6.85457 L 3.73313 12.3465 C 3.23886 12.6394 2.95511 12.978 2.85442 13.3533 L 2.85442 7.47699 C 2.85442 6.64405 2.43337 6.25962 1.63705 6.25962 L -0.449872 6.25962 C -1.2462 6.25962 -1.66724 6.68067 -1.66724 7.47699 L -1.66724 19.9436 C -1.66724 20.7491 -1.24619 21.1701 -0.44987 21.1701 Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - forward.end.alt.fill\npub static SKIP_FORWARD: SvgIcon = SvgIcon {\n    svg_path: \"M 0.909093 20.6922 C 1.28439 20.6922 1.58649 20.5732 1.96179 20.3535 L 11.2888 14.8708 C 11.783 14.5687 12.0668 14.2392 12.1675 13.8548 L 12.1675 19.127 C 12.1675 20.1522 12.7716 20.6922 13.4856 20.6922 C 13.8608 20.6922 14.172 20.5732 14.5382 20.3535 L 23.8653 14.8708 C 24.3595 14.5687 24.6433 14.2392 24.744 13.8548 L 24.7439 19.7403 C 24.7439 20.5458 25.1649 20.9668 25.9704 20.9668 L 28.0482 20.9668 C 28.8537 20.9668 29.2656 20.5458 29.2656 19.7403 L 29.2656 7.27371 C 29.2656 6.44077 28.8537 6.05634 28.0482 6.05634 L 25.9704 6.05634 C 25.1649 6.05634 24.7439 6.47739 24.7439 7.27371 L 24.7439 13.15 C 24.6432 12.7747 24.3686 12.4361 23.8652 12.1431 L 14.5381 6.65129 C 14.1628 6.43161 13.8608 6.31262 13.4855 6.31262 C 12.7715 6.31262 12.1674 6.86181 12.1674 7.88697 L 12.1675 13.15 C 12.0668 12.7747 11.783 12.436 11.2887 12.1431 L 1.96169 6.65129 C 1.58649 6.43162 1.28439 6.31263 0.909093 6.31263 C 0.185992 6.31263 -0.40891 6.86182 -0.40891 7.88698 L -0.40891 19.127 C -0.40891 20.1522 0.185992 20.6922 0.909093 20.6922 Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n// ...\npub static PLAY_SEQUENTIAL: SvgIcon = SvgIcon {\n    svg_path: \"M3 8C3 8.55228 2.55228 9 2 9C1.44772 9 1 8.55228 1 8C1 7.44772 1.44772 7 2 7C2.55228 7 3 7.44772 3 8Z M7 8C7 8.55228 6.55228 9 6 9C5.44772 9 5 8.55228 5 8C5 7.44772 5.44772 7 6 7C6.55228 7 7 7.44772 7 8Z M11 8C11 8.55228 10.5523 9 10 9C9.44772 9 9 8.55228 9 8C9 7.44772 9.44772 7 10 7C10.5523 7 11 7.44772 11 8Z M15 8C15 8.55228 14.5523 9 14 9C13.4477 9 13 8.55228 13 8C13 7.44772 13.4477 7 14 7C14.5523 7 15 7.44772 15 8Z\",\n    svg_size: Size::new(16.0, 16.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - shuffle\npub static PLAY_SHUFFLE: SvgIcon = SvgIcon {\n    svg_path: \"M1.90792 10.4894C1.90792 10.846 2.17411 11.0971 2.5558 11.0971H3.74107C4.63504 11.0971 5.17746 10.8309 5.78516 10.1077L6.92522 8.75167L8.0452 10.0876C8.66797 10.8309 9.28069 11.1021 10.1797 11.1021H11.0737V12.202C11.0737 12.5033 11.2645 12.6942 11.5709 12.6942C11.7065 12.6942 11.832 12.644 11.9325 12.5636L13.9113 10.9163C14.1574 10.7154 14.1523 10.389 13.9113 10.1881L11.9325 8.53572C11.832 8.45034 11.7065 8.40011 11.5709 8.40011C11.2645 8.40011 11.0737 8.59096 11.0737 8.8923V9.87668H10.2048C9.63728 9.87668 9.28571 9.69085 8.87388 9.20368L7.70871 7.82255L8.87891 6.43136C9.29576 5.93415 9.61719 5.76339 10.1747 5.76339H11.0737V6.76284C11.0737 7.06417 11.2645 7.25502 11.5709 7.25502C11.7065 7.25502 11.832 7.2048 11.9325 7.12444L13.9113 5.47712C14.1574 5.27623 14.1523 4.94978 13.9113 4.74888L11.9325 3.09654C11.832 3.01116 11.7065 2.96094 11.5709 2.96094C11.2645 2.96094 11.0737 3.15179 11.0737 3.45313V4.53795H10.1847C9.25558 4.53795 8.66797 4.79911 8.01004 5.59263L6.92522 6.88337L5.78516 5.53237C5.17746 4.80915 4.59989 4.53795 3.70592 4.53795H2.5558C2.17411 4.53795 1.90792 4.79409 1.90792 5.15067C1.90792 5.50725 2.17913 5.76339 2.5558 5.76339H3.66071C4.20313 5.76339 4.56473 5.9442 4.97656 6.43638L6.13672 7.81752L4.97656 9.20368C4.55971 9.69587 4.23326 9.87668 3.69587 9.87668H2.5558C2.17913 9.87668 1.90792 10.1328 1.90792 10.4894Z\",\n    svg_size: Size::new(16.0, 20.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - repeat.1\npub static PLAY_LOOP_TRACK: SvgIcon = SvgIcon {\n    svg_path: \"M13.0273 6.49665C13.3789 6.49665 13.5848 6.30581 13.5848 5.92411V3.56864C13.5848 3.16686 13.3186 2.90067 12.9219 2.90067C12.6004 2.90067 12.4046 3.00112 12.1484 3.19197L11.5006 3.68416C11.3499 3.79967 11.2946 3.91016 11.2946 4.04576C11.2946 4.24665 11.4403 4.40235 11.6663 4.40235C11.7667 4.40235 11.8521 4.36719 11.9375 4.3019L12.4196 3.91016H12.4598V5.92411C12.4598 6.30581 12.6708 6.49665 13.0273 6.49665ZM2.35491 7.37054C2.35491 7.72712 2.62612 7.99833 2.9827 7.99833C3.34431 7.99833 3.61049 7.72712 3.61049 7.37054V7.04409C3.61049 6.22545 4.17299 5.69811 5.02679 5.69811H7.56808V6.74777C7.56808 7.04911 7.75893 7.23996 8.06027 7.23996C8.19587 7.23996 8.32645 7.18974 8.4269 7.10938L10.4057 5.46206C10.6468 5.26116 10.6468 4.93471 10.4057 4.73382L8.4269 3.08148C8.32645 2.9961 8.19587 2.94587 8.06027 2.94587C7.75893 2.94587 7.56808 3.13672 7.56808 3.43806V4.46261H5.12723C3.42467 4.46261 2.35491 5.41183 2.35491 6.92355V7.37054ZM7.19141 8.74665C7.19141 8.44532 7.00056 8.24944 6.69922 8.24944C6.56362 8.24944 6.43304 8.30469 6.33259 8.38505L4.35882 10.0324C4.11272 10.2282 4.11272 10.5547 4.35882 10.7606L6.33259 12.4129C6.43304 12.4983 6.56362 12.5486 6.69922 12.5486C7.00056 12.5486 7.19141 12.3577 7.19141 12.0564V11.0268H10.8677C12.5703 11.0268 13.635 10.0725 13.635 8.56585V8.11886C13.635 7.75726 13.3638 7.48605 13.0073 7.48605C12.6507 7.48605 12.3795 7.75726 12.3795 8.11886V8.44532C12.3795 9.25893 11.822 9.7913 10.9632 9.7913H7.19141V8.74665Z\",\n    svg_size: Size::new(16.0, 16.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - repeat\npub static PLAY_LOOP_ALL: SvgIcon = SvgIcon {\n    svg_path: \"M2.35993 7.37053C2.35993 7.71707 2.64118 7.99832 2.98772 7.99832C3.33929 7.99832 3.61551 7.71707 3.61551 7.37053V7.04408C3.61551 6.22545 4.17801 5.6981 5.03181 5.6981H8.80357V6.74777C8.80357 7.04911 8.99442 7.23995 9.30078 7.23995C9.43638 7.23995 9.56194 7.18973 9.66239 7.10937L11.6412 5.46205C11.8873 5.26618 11.8823 4.93471 11.6412 4.73382L9.66239 3.08147C9.56194 2.99609 9.43638 2.94587 9.30078 2.94587C8.99442 2.94587 8.80357 3.13672 8.80357 3.43806V4.46261H5.13225C3.42969 4.46261 2.35993 5.41183 2.35993 6.92355V7.37053ZM7.19643 8.74665C7.19643 8.44531 7.00558 8.24944 6.70424 8.24944C6.56864 8.24944 6.43806 8.30469 6.33761 8.38504L4.36384 10.0324C4.11775 10.2282 4.11775 10.5547 4.36384 10.7606L6.33761 12.4129C6.43806 12.4983 6.56864 12.5485 6.70424 12.5485C7.00558 12.5485 7.19643 12.3577 7.19643 12.0564V11.0268H10.8728C12.5753 11.0268 13.6401 10.0725 13.6401 8.56585V8.11886C13.6401 7.7673 13.3638 7.48605 13.0123 7.48605C12.6657 7.48605 12.3845 7.7673 12.3845 8.11886V8.44531C12.3845 9.25893 11.827 9.79129 10.9682 9.79129H7.19643V8.74665Z\",\n    svg_size: Size::new(16.0, 16.0),\n    op: PaintOp::Fill,\n};\n\n// SFsymbols - music.note\npub static MUSIC_NOTE: SvgIcon = SvgIcon {\n    svg_path: \"M 41.83984375,16.7578125 L 41.83984375,9.302734375 C 41.83984375,8.099609375 40.89453125,7.3046875 39.712890625,7.5625 L 28.390625,9.990234375 C 27.015625,10.291015625 26.328125,10.978515625 26.328125,12.1171875 L 26.478515625,34.9765625 C 26.478515625,36.05078125 25.94140625,36.759765625 24.99609375,36.953125 L 21.623046875,37.662109375 C 17.43359375,38.54296875 15.478515625,40.6484375 15.478515625,43.806640625 C 15.478515625,46.986328125 17.90625,49.19921875 21.365234375,49.19921875 C 24.458984375,49.19921875 29.03515625,46.96484375 29.03515625,40.86328125 L 29.03515625,22.193359375 C 29.03515625,21.033203125 29.271484375,20.796875 30.32421875,20.5390625 L 40.59375,18.283203125 C 41.3671875,18.1328125 41.83984375,17.53125 41.83984375,16.7578125 Z\",\n    svg_size: Size::new(52.0, 54.0),\n    op: PaintOp::Fill,\n};\n\n// SF Pro Regular - exclamationmark.circle\npub static ERROR: SvgIcon = SvgIcon {\n    svg_path: \"M13.9912 22.7422C18.9746 22.7422 23.0879 18.6289 23.0879 13.6543C23.0879 8.67969 18.9658 4.56641 13.9824 4.56641C9.00781 4.56641 4.90332 8.67969 4.90332 13.6543C4.90332 18.6289 9.0166 22.7422 13.9912 22.7422ZM13.9912 20.9316C9.95703 20.9316 6.73145 17.6885 6.73145 13.6543C6.73145 9.62012 9.95703 6.38574 13.9824 6.38574C18.0166 6.38574 21.2598 9.62012 21.2686 13.6543C21.2773 17.6885 18.0254 20.9316 13.9912 20.9316ZM13.9824 15.1133C14.4658 15.1133 14.7471 14.8408 14.7559 14.3311L14.8877 10.1035C14.9053 9.58496 14.5186 9.20703 13.9736 9.20703C13.4287 9.20703 13.0508 9.57617 13.0684 10.0947L13.1914 14.3311C13.209 14.832 13.4902 15.1133 13.9824 15.1133ZM13.9824 18.0312C14.5537 18.0312 15.0195 17.6182 15.0195 17.0557C15.0195 16.502 14.5625 16.0889 13.9824 16.0889C13.4111 16.0889 12.9453 16.502 12.9453 17.0557C12.9453 17.6094 13.4199 18.0312 13.9824 18.0312Z\",\n    svg_size: Size::new(28.0, 28.0),\n    op: PaintOp::Fill,\n};\n\n// SF Pro Regular - magnifyingglass.circle\npub static SEARCH: SvgIcon = SvgIcon {\n    svg_path: \"M10.9912 19.7422C15.9746 19.7422 20.0879 15.6289 20.0879 10.6543C20.0879 5.67969 15.9658 1.56641 10.9824 1.56641C6.00781 1.56641 1.90332 5.67969 1.90332 10.6543C1.90332 15.6289 6.0166 19.7422 10.9912 19.7422ZM10.9912 17.9316C6.95703 17.9316 3.73145 14.6885 3.73145 10.6543C3.73145 6.62012 6.95703 3.38574 10.9824 3.38574C15.0166 3.38574 18.2598 6.62012 18.2686 10.6543C18.2773 14.6885 15.0254 17.9316 10.9912 17.9316ZM10.0771 13.3525C10.7539 13.3525 11.3955 13.168 11.9404 12.834L14.0498 14.9346C14.2344 15.1367 14.4541 15.2246 14.7178 15.2246C15.1924 15.2246 15.5352 14.873 15.5352 14.3809C15.5352 14.1611 15.4297 13.9502 15.2715 13.7832L13.1533 11.665C13.5225 11.0938 13.7334 10.417 13.7334 9.69629C13.7334 7.68359 12.0898 6.04004 10.0771 6.04004C8.07324 6.04004 6.4209 7.69238 6.4209 9.69629C6.4209 11.709 8.07324 13.3525 10.0771 13.3525ZM10.0771 12.043C8.79395 12.043 7.72168 10.9883 7.72168 9.69629C7.72168 8.41309 8.79395 7.34961 10.0771 7.34961C11.3691 7.34961 12.4238 8.4043 12.4238 9.69629C12.4238 10.9883 11.3691 12.043 10.0771 12.043Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - heart.circle\npub static HEART: SvgIcon = SvgIcon {\n    svg_path: \"M11.0879 20.1758C16.0713 20.1758 20.1846 16.0625 20.1846 11.0879C20.1846 6.11328 16.0625 2 11.0791 2C6.10449 2 2 6.11328 2 11.0879C2 16.0625 6.11328 20.1758 11.0879 20.1758ZM11.0879 18.3652C7.05371 18.3652 3.82812 15.1221 3.82812 11.0879C3.82812 7.05371 7.05371 3.81934 11.0791 3.81934C15.1133 3.81934 18.3564 7.05371 18.3652 11.0879C18.374 15.1221 15.1221 18.3652 11.0879 18.3652ZM9.01367 7.29102C7.57227 7.29102 6.5 8.38965 6.5 9.94531C6.5 12.3447 9.10156 14.4277 10.6309 15.3857C10.7803 15.4736 10.9824 15.5967 11.1055 15.5967C11.2285 15.5967 11.4131 15.4736 11.5537 15.3857C13.0654 14.4102 15.6846 12.3447 15.6846 9.94531C15.6846 8.38965 14.6123 7.29102 13.1621 7.29102C12.2305 7.29102 11.501 7.82715 11.0879 8.57422C10.6748 7.82715 9.96289 7.29102 9.01367 7.29102Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n\n// SF Pro Regular - person.crop.circle\npub static ARTIST: SvgIcon = SvgIcon {\n    svg_path: \"M10.9912 19.7422C15.9746 19.7422 20.0879 15.6289 20.0879 10.6543C20.0879 5.67969 15.9658 1.56641 10.9824 1.56641C6.00781 1.56641 1.90332 5.67969 1.90332 10.6543C1.90332 15.6289 6.0166 19.7422 10.9912 19.7422ZM10.9912 13.6953C8.5127 13.6953 6.58789 14.583 5.65625 15.6025C4.46094 14.3105 3.73145 12.5703 3.73145 10.6543C3.73145 6.62012 6.95703 3.38574 10.9824 3.38574C15.0166 3.38574 18.2598 6.62012 18.2686 10.6543C18.2686 12.5703 17.5391 14.3105 16.335 15.6113C15.4033 14.583 13.4785 13.6953 10.9912 13.6953ZM10.9912 12.2539C12.6963 12.2715 14.0234 10.8125 14.0234 8.93164C14.0234 7.15625 12.6875 5.6709 10.9912 5.6709C9.30371 5.6709 7.95898 7.15625 7.96777 8.93164C7.97656 10.8125 9.29492 12.2451 10.9912 12.2539Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - record.circle\npub static ALBUM: SvgIcon = SvgIcon {\n    svg_path: \"M10.9912 19.7422C15.9746 19.7422 20.0879 15.6289 20.0879 10.6543C20.0879 5.67969 15.9658 1.56641 10.9824 1.56641C6.00781 1.56641 1.90332 5.67969 1.90332 10.6543C1.90332 15.6289 6.0166 19.7422 10.9912 19.7422ZM10.9912 17.9316C6.95703 17.9316 3.73145 14.6885 3.73145 10.6543C3.73145 6.62012 6.95703 3.38574 10.9824 3.38574C15.0166 3.38574 18.2598 6.62012 18.2686 10.6543C18.2773 14.6885 15.0254 17.9316 10.9912 17.9316ZM11 14.0996C12.9072 14.0996 14.4453 12.5615 14.4453 10.6455C14.4453 8.74707 12.9072 7.2002 11 7.2002C9.08398 7.2002 7.5459 8.74707 7.5459 10.6455C7.5459 12.5615 9.08398 14.0996 11 14.0996Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - list.bullet.circle\npub static PLAYLIST: SvgIcon = SvgIcon {\n    svg_path: \"M11 19.7334C15.9658 19.7334 20.0791 15.6289 20.0791 10.6543C20.0791 5.68848 15.9658 1.5752 10.9912 1.5752C6.02539 1.5752 1.9209 5.68848 1.9209 10.6543C1.9209 15.6289 6.03418 19.7334 11 19.7334ZM11 17.9492C6.95703 17.9492 3.71387 14.6973 3.71387 10.6543C3.71387 6.61133 6.94824 3.36816 10.9912 3.36816C15.0342 3.36816 18.2861 6.61133 18.2949 10.6543C18.2949 14.6973 15.043 17.9492 11 17.9492ZM6.89551 8.60645C7.31738 8.60645 7.66895 8.25488 7.66895 7.83301C7.66895 7.40234 7.32617 7.05957 6.89551 7.05957C6.46484 7.05957 6.12207 7.40234 6.12207 7.83301C6.12207 8.26367 6.46484 8.60645 6.89551 8.60645ZM9.01367 8.39551H15.2891C15.6055 8.39551 15.8516 8.14941 15.8516 7.83301C15.8516 7.5166 15.6055 7.27051 15.2891 7.27051H9.01367C8.69727 7.27051 8.45117 7.5166 8.45117 7.83301C8.45117 8.14062 8.70605 8.39551 9.01367 8.39551ZM6.89551 11.4365C7.31738 11.4365 7.66895 11.0762 7.66895 10.6543C7.66895 10.2324 7.31738 9.88086 6.89551 9.88086C6.46484 9.88086 6.12207 10.2324 6.12207 10.6543C6.12207 11.085 6.46484 11.4365 6.89551 11.4365ZM9.01367 11.2168H15.2891C15.6055 11.2168 15.8516 10.9795 15.8516 10.6543C15.8516 10.3379 15.6055 10.1006 15.2891 10.1006H9.01367C8.70605 10.1006 8.45117 10.3467 8.45117 10.6543C8.45117 10.9707 8.70605 11.2168 9.01367 11.2168ZM6.89551 14.2578C7.32617 14.2578 7.66895 13.915 7.66895 13.4844C7.66895 13.0625 7.31738 12.7109 6.89551 12.7109C6.46484 12.7109 6.12207 13.0537 6.12207 13.4844C6.12207 13.915 6.46484 14.2578 6.89551 14.2578ZM9.01367 14.0469H15.2891C15.6055 14.0469 15.8516 13.8008 15.8516 13.4844C15.8516 13.168 15.6055 12.9219 15.2891 12.9219H9.01367C8.70605 12.9219 8.45117 13.1768 8.45117 13.4844C8.45117 13.8008 8.69727 14.0469 9.01367 14.0469Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n// SFSymbols - house.fill\npub static HOME: SvgIcon = SvgIcon {\n    svg_path: \"M5.47851563 25.97460938c0 .23632812.171875.4296875.47265624.4296875.23632813 0 .34375-.10742188.47265626-.23632813L27.54296874 6.89648437c.32226563-.30078124.6875-.45117187 1.05273438-.45117187.32226562 0 .6875.171875.98828124.45117188L50.703125 26.16796874c.12890625.12890625.23632813.23632813.47265625.23632813.27929688 0 .47265625-.19335938.47265625-.4296875 0-.19335938-.06445313-.30078126-.19335938-.4296875l-5.95117187-5.45703126V6.12304689c0-.45117188-.32226563-.75195313-.75195313-.75195313h-.90234375c-.4296875 0-.75195312.30078125-.75195312.75195313v11.7734375L30.3359375 6.16601561c-.55859375-.49414062-1.18164063-.73046874-1.8046875-.73046874-.6015625 0-1.203125.23632812-1.74023438.73046874L5.671875 25.54492189c-.15039063.12890624-.19335938.23632812-.19335938.4296875ZM10.52734374 44.515625c0 2.25585938 1.33203125 3.58789063 3.609375 3.58789063h9.13085938V33c0-.70898438.47265624-1.16015625 1.18164062-1.16015625h8.22851563c.70898437 0 1.16015625.45117188 1.16015625 1.16015625v15.10351563h9.15234375c2.25585937 0 3.58789062-1.33203125 3.58789062-3.58789063V25.28710937L29.79882812 10.44140626c-.47265624-.40820313-.90234374-.6015625-1.33203124-.6015625-.4296875 0-.88085938.19335938-1.31054688.6015625L10.52734375 25.84570313Z\",\n    svg_size: Size::new(57.0, 53.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - mic.circle\npub static PODCAST: SvgIcon = SvgIcon {\n    svg_path: \"M10.9957 20C15.9285 20 20 15.9265 20 11C20 6.0735 15.9198 2 10.987 2C6.06283 2 2 6.0735 2 11C2 15.9265 6.07153 20 10.9957 20ZM10.9957 18.207C7.00242 18.207 3.80957 14.9952 3.80957 11C3.80957 7.00484 7.00242 3.80174 10.987 3.80174C14.9802 3.80174 18.1904 7.00484 18.1991 11C18.2078 14.9952 14.9889 18.207 10.9957 18.207ZM10.9957 12.5928C11.8395 12.5928 12.4746 11.9313 12.4746 11.0348V7.42263C12.4746 6.51741 11.8395 5.8646 10.9957 5.8646C10.1431 5.8646 9.50797 6.51741 9.50797 7.42263V11.0348C9.50797 11.9313 10.1431 12.5928 10.9957 12.5928ZM8.82939 16.1789H13.1619C13.4316 16.1789 13.6752 15.9439 13.6752 15.6741C13.6752 15.3956 13.4403 15.1605 13.1619 15.1605H11.5002V14.3598C13.2228 14.1509 14.4234 12.854 14.4234 11.087V10.0077C14.4234 9.73791 14.1885 9.51161 13.9188 9.51161C13.6404 9.51161 13.4055 9.73791 13.4055 10.0077V11.0783C13.4055 12.4536 12.3876 13.4371 10.987 13.4371C9.59497 13.4371 8.57709 12.4536 8.57709 11.0783V10.0077C8.57709 9.73791 8.34219 9.51161 8.0638 9.51161C7.7941 9.51161 7.55921 9.73791 7.55921 10.0077V11.087C7.55921 12.854 8.76849 14.1596 10.4911 14.3598V15.1605H8.82939C8.55099 15.1605 8.30739 15.3956 8.30739 15.6741C8.30739 15.9526 8.55099 16.1789 8.82939 16.1789Z\",\n    svg_size: Size::new(22.0, 22.0),\n    op: PaintOp::Fill,\n};\n// Bootstrap - explicit-fill\npub static EXPLICIT: SvgIcon = SvgIcon {\n    svg_path: \"M3.75 0A3.75 3.75 0 0 0 0 3.75v16.5A3.75 3.75 0 0 0 3.75 24h16.5A3.75 3.75 0 0 0 24 20.25V3.75A3.75 3.75 0 0 0 20.25 0Zm6.49 16.32h5.51V18h-7.5V6h7.5v1.68h-5.51v3.42h5.18v1.6h-5.18v3.62z\",\n    svg_size: Size::new(24.0, 24.0),\n    op: PaintOp::Fill,\n};\n// SF Pro Regular - plus.circle\npub static CIRCLE_PLUS: SvgIcon = SvgIcon {\n    svg_path: \"M11.9531 23.9062C18.4922 23.9062 23.9062 18.4805 23.9062 11.9531C23.9062 5.41406 18.4805 0 11.9414 0C5.41406 0 0 5.41406 0 11.9531C0 18.4805 5.42578 23.9062 11.9531 23.9062ZM11.9531 21.9141C6.42188 21.9141 2.00391 17.4844 2.00391 11.9531C2.00391 6.42188 6.41016 1.99219 11.9414 1.99219C17.4727 1.99219 21.9141 6.42188 21.9141 11.9531C21.9141 17.4844 17.4844 21.9141 11.9531 21.9141ZM6.51562 11.9531C6.51562 12.5273 6.91406 12.9141 7.51172 12.9141L10.957 12.9141L10.957 16.3711C10.957 16.957 11.3555 17.3672 11.9297 17.3672C12.5156 17.3672 12.9258 16.9688 12.9258 16.3711L12.9258 12.9141L16.3828 12.9141C16.9688 12.9141 17.3789 12.5273 17.3789 11.9531C17.3789 11.3672 16.9688 10.957 16.3828 10.957L12.9258 10.957L12.9258 7.51172C12.9258 6.91406 12.5156 6.50391 11.9297 6.50391C11.3555 6.50391 10.957 6.91406 10.957 7.51172L10.957 10.957L7.51172 10.957C6.91406 10.957 6.51562 11.3672 6.51562 11.9531Z\",\n    svg_size: Size::new(24.0, 24.0),\n    op: PaintOp::Fill,\n};\n\n// SF Pro Regular - checkmark.circle.fill\npub static CIRCLE_CHECK: SvgIcon = SvgIcon {\n    svg_path: \"M23.9062 11.9531C23.9062 18.4805 18.4922 23.9062 11.9531 23.9062C5.42578 23.9062 0 18.4805 0 11.9531C0 5.41406 5.41406 0 11.9414 0C18.4805 0 23.9062 5.41406 23.9062 11.9531ZM15.5977 7.30078L10.5938 15.3398L8.21484 12.2695C7.92188 11.8828 7.66406 11.7773 7.32422 11.7773C6.79688 11.7773 6.38672 12.2109 6.38672 12.7383C6.38672 13.0078 6.49219 13.2656 6.66797 13.5L9.60938 17.1094C9.91406 17.5195 10.2422 17.6836 10.6406 17.6836C11.0391 17.6836 11.3789 17.4961 11.625 17.1094L17.1328 8.4375C17.2734 8.19141 17.4258 7.92188 17.4258 7.66406C17.4258 7.11328 16.9453 6.76172 16.4297 6.76172C16.125 6.76172 15.8203 6.94922 15.5977 7.30078Z\",\n    svg_size: Size::new(24.0, 24.0),\n    op: PaintOp::Fill,\n};\n\n// LastFM Logo:\n// pub static LASTFM: SvgIcon = SvgIcon {\n//     svg_path: \"M2.519 7.88C3.62 6.7 5.282 6 7.5 6c0.95 0 1.763 0.182 2.454 0.544 0.694 0.364 1.208 0.88 1.598 1.462 0.668 0.996 1.016 2.27 1.316 3.371l0.097 0.356c0.352 1.269 0.695 2.31 1.33 3.058C14.867 15.468 15.77 16 17.5 16c0.433 0 1.435 -0.078 2.29 -0.382 0.917 -0.325 1.21 -0.718 1.21 -1.118 0 -0.217 -0.075 -0.412 -0.558 -0.665 -0.507 -0.266 -1.205 -0.45 -2.073 -0.677l-0.123 -0.033c-0.848 -0.223 -1.868 -0.497 -2.67 -0.981C14.713 11.622 14 10.788 14 9.5c0 -0.884 0.526 -1.766 1.272 -2.391C16.05 6.456 17.154 6 18.5 6c2.828 0 4.185 1.616 4.47 2.757l-1.94 0.486C20.982 9.05 20.472 8 18.5 8c-0.883 0 -1.53 0.294 -1.943 0.641 -0.448 0.375 -0.557 0.743 -0.557 0.859 0 0.397 0.163 0.661 0.61 0.932 0.512 0.31 1.242 0.522 2.145 0.759l0.2 0.052c0.784 0.205 1.696 0.444 2.415 0.82 0.83 0.435 1.63 1.18 1.63 2.437 0 1.762 -1.457 2.619 -2.54 3.003 -1.145 0.406 -2.393 0.497 -2.96 0.497 -2.216 0 -3.716 -0.718 -4.732 -1.916 -0.955 -1.127 -1.389 -2.586 -1.73 -3.817l-0.086 -0.313c-0.325 -1.18 -0.586 -2.127 -1.061 -2.835a2.34 2.34 0 0 0 -0.865 -0.804C8.674 8.131 8.19 8 7.5 8c-1.782 0 -2.87 0.55 -3.519 1.245C3.318 9.955 3 10.94 3 12c0 0.925 0.472 1.933 1.27 2.73C5.067 15.527 6.075 16 7 16c0.888 0 1.566 -0.148 2.039 -0.317a3.32 3.32 0 0 0 0.55 -0.25 1.685 1.685 0 0 0 0.204 -0.14l1.414 1.414C10.64 17.276 9.19 18 7 18c-1.575 0 -3.067 -0.777 -4.145 -1.855C1.778 15.067 1 13.575 1 12c0 -1.44 0.432 -2.956 1.519 -4.12Z\",\n//     svg_size: Size::new(22.0, 22.0),\n//     op: PaintOp::Fill,\n// };\n\n#[derive(Copy, Clone)]\npub enum PaintOp {\n    Fill,\n}\n\n#[derive(Clone)]\npub struct SvgIcon {\n    svg_path: &'static str,\n    svg_size: Size,\n    op: PaintOp,\n}\n\nimpl SvgIcon {\n    pub fn scale(&self, to_size: impl Into<Size>) -> Icon {\n        let to_size = to_size.into();\n        let bez_path = BezPath::from_svg(self.svg_path).expect(\"Failed to parse SVG\");\n        let scale = Affine::scale_non_uniform(\n            to_size.width / self.svg_size.width,\n            to_size.height / self.svg_size.height,\n        );\n        Icon::new(self.op, bez_path, to_size, scale)\n    }\n}\n\n#[derive(Clone)]\npub struct Icon {\n    op: PaintOp,\n    bez_path: BezPath,\n    size: Size,\n    scale: Affine,\n    color: KeyOrValue<Color>,\n}\n\nimpl Icon {\n    pub fn new(op: PaintOp, bez_path: BezPath, size: Size, scale: Affine) -> Self {\n        Icon {\n            op,\n            bez_path,\n            size,\n            scale,\n            color: theme::ICON_COLOR.into(),\n        }\n    }\n\n    pub fn with_color(mut self, color: impl Into<KeyOrValue<Color>>) -> Self {\n        self.set_color(color);\n        self\n    }\n\n    pub fn set_color(&mut self, color: impl Into<KeyOrValue<Color>>) {\n        self.color = color.into();\n    }\n}\n\nimpl<T> Widget<T> for Icon {\n    fn event(&mut self, _ctx: &mut EventCtx, _ev: &Event, _data: &mut T, _env: &Env) {}\n\n    fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _ev: &LifeCycle, _data: &T, _env: &Env) {}\n\n    fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {}\n\n    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, _env: &Env) -> Size {\n        bc.constrain(self.size)\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, env: &Env) {\n        let color = self.color.resolve(env);\n        ctx.with_save(|ctx| {\n            ctx.transform(self.scale);\n            match self.op {\n                PaintOp::Fill => ctx.fill(&self.bez_path, &color),\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/link.rs",
    "content": "use druid::{widget::prelude::*, Color, Data, KeyOrValue, Point, RoundedRectRadii, WidgetPod};\n\nuse crate::ui::theme;\n\npub struct Link<T> {\n    inner: WidgetPod<T, Box<dyn Widget<T>>>,\n    border_color: KeyOrValue<Color>,\n    border_width: KeyOrValue<f64>,\n    corner_radius: KeyOrValue<RoundedRectRadii>,\n    is_active: Option<Box<dyn Fn(&T, &Env) -> bool>>,\n}\n\nimpl<T: Data> Link<T> {\n    pub fn new(inner: impl Widget<T> + 'static) -> Self {\n        Self {\n            inner: WidgetPod::new(inner).boxed(),\n            border_color: theme::LINK_HOT_COLOR.into(),\n            border_width: 0.0.into(),\n            corner_radius: RoundedRectRadii::from(0.0).into(),\n            is_active: None,\n        }\n    }\n\n    pub fn border(\n        mut self,\n        color: impl Into<KeyOrValue<Color>>,\n        width: impl Into<KeyOrValue<f64>>,\n    ) -> Self {\n        self.border_color = color.into();\n        self.border_width = width.into();\n        self\n    }\n\n    pub fn rounded(mut self, radius: impl Into<KeyOrValue<RoundedRectRadii>>) -> Self {\n        self.corner_radius = radius.into();\n        self\n    }\n\n    pub fn circle(self) -> Self {\n        self.rounded(RoundedRectRadii::from(f64::INFINITY))\n    }\n\n    pub fn active(mut self, predicate: impl Fn(&T, &Env) -> bool + 'static) -> Self {\n        self.is_active = Some(Box::new(predicate));\n        self\n    }\n}\n\nimpl<T: Data> Widget<T> for Link<T> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.inner.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        if let LifeCycle::HotChanged(_) = event {\n            ctx.request_paint();\n        }\n        self.inner.lifecycle(ctx, event, data, env)\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        self.inner.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let size = self.inner.layout(ctx, bc, data, env);\n        self.inner.set_origin(ctx, Point::ORIGIN);\n        size\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        let background = if ctx.is_hot() {\n            env.get(theme::LINK_HOT_COLOR)\n        } else {\n            let is_active = self\n                .is_active\n                .as_ref()\n                .is_some_and(|predicate| predicate(data, env));\n            if is_active {\n                env.get(theme::LINK_ACTIVE_COLOR)\n            } else {\n                env.get(theme::LINK_COLD_COLOR)\n            }\n        };\n        let border_color = self.border_color.resolve(env);\n        let border_width = self.border_width.resolve(env);\n        let visible_background = background.as_rgba_u32() & 0x00000FF > 0;\n        let visible_border = border_color.as_rgba_u32() & 0x000000FF > 0 && border_width > 0.0;\n        if visible_background || visible_border {\n            let corner_radius = self.corner_radius.resolve(env);\n            let rounded_rect = ctx\n                .size()\n                .to_rect()\n                .inset(-border_width / 2.0)\n                .to_rounded_rect(corner_radius);\n            if visible_border {\n                ctx.stroke(rounded_rect, &border_color, border_width);\n            }\n            if visible_background {\n                ctx.fill(rounded_rect, &background);\n            }\n        }\n        self.inner.paint(ctx, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/maybe.rs",
    "content": "// Copyright 2020 The xi-editor Authors.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//! A widget for optional data, with different `Some` and `None` children.\n\nuse druid::{\n    BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx,\n    Point, Size, UpdateCtx, Widget, WidgetExt, WidgetPod,\n};\n\nuse druid::widget::SizedBox;\n\n/// A widget that switches between two possible child views, for `Data` that\n/// is `Option<T>`.\npub struct Maybe<T> {\n    some_maker: Box<dyn Fn() -> Box<dyn Widget<T>>>,\n    none_maker: Box<dyn Fn() -> Box<dyn Widget<()>>>,\n    widget: MaybeWidget<T>,\n}\n\n#[allow(clippy::large_enum_variant)]\nenum MaybeWidget<T> {\n    Some(WidgetPod<T, Box<dyn Widget<T>>>),\n    None(WidgetPod<(), Box<dyn Widget<()>>>),\n}\n\nimpl<T: Data> Maybe<T> {\n    /// Create a new `Maybe` widget with a `Some` and a `None` branch.\n    pub fn new<W1, W2>(\n        // we make these generic so that the caller doesn't have to explicitly\n        // box. We don't technically *need* to box, but it seems simpler.\n        some_maker: impl Fn() -> W1 + 'static,\n        none_maker: impl Fn() -> W2 + 'static,\n    ) -> Maybe<T>\n    where\n        W1: Widget<T> + 'static,\n        W2: Widget<()> + 'static,\n    {\n        let widget = MaybeWidget::Some(WidgetPod::new(some_maker().boxed()));\n        Maybe {\n            some_maker: Box::new(move || some_maker().boxed()),\n            none_maker: Box::new(move || none_maker().boxed()),\n            widget,\n        }\n    }\n\n    /// Create a new `Maybe` widget where the `None` branch is an empty widget.\n    #[allow(dead_code)]\n    pub fn or_empty<W1: Widget<T> + 'static>(some_maker: impl Fn() -> W1 + 'static) -> Maybe<T> {\n        Self::new(some_maker, SizedBox::empty)\n    }\n\n    fn rebuild_widget(&mut self, is_some: bool) {\n        if is_some {\n            self.widget = MaybeWidget::Some(WidgetPod::new((self.some_maker)()));\n        } else {\n            self.widget = MaybeWidget::None(WidgetPod::new((self.none_maker)()));\n        }\n    }\n}\n\nimpl<T: Data> Widget<Option<T>> for Maybe<T> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut Option<T>, env: &Env) {\n        if data.is_some() == self.widget.is_some() {\n            match data.as_mut() {\n                Some(d) => self.widget.with_some(|w| w.event(ctx, event, d, env)),\n                None => self.widget.with_none(|w| w.event(ctx, event, &mut (), env)),\n            };\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &Option<T>,\n        env: &Env,\n    ) {\n        if data.is_some() != self.widget.is_some() {\n            // possible if getting lifecycle after an event that changed the data,\n            // or on WidgetAdded\n            self.rebuild_widget(data.is_some());\n        }\n        assert_eq!(data.is_some(), self.widget.is_some(), \"{event:?}\");\n        match data.as_ref() {\n            Some(d) => self.widget.with_some(|w| w.lifecycle(ctx, event, d, env)),\n            None => self.widget.with_none(|w| w.lifecycle(ctx, event, &(), env)),\n        };\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &Option<T>, data: &Option<T>, env: &Env) {\n        if old_data.is_some() != data.is_some() {\n            self.rebuild_widget(data.is_some());\n            ctx.children_changed();\n        } else {\n            match data {\n                Some(new) => self.widget.with_some(|w| w.update(ctx, new, env)),\n                None => self.widget.with_none(|w| w.update(ctx, &(), env)),\n            };\n        }\n    }\n\n    fn layout(\n        &mut self,\n        ctx: &mut LayoutCtx,\n        bc: &BoxConstraints,\n        data: &Option<T>,\n        env: &Env,\n    ) -> Size {\n        match data.as_ref() {\n            Some(d) => self.widget.with_some(|w| {\n                let size = w.layout(ctx, bc, d, env);\n                w.set_origin(ctx, Point::ORIGIN);\n                size\n            }),\n            None => self.widget.with_none(|w| {\n                let size = w.layout(ctx, bc, &(), env);\n                w.set_origin(ctx, Point::ORIGIN);\n                size\n            }),\n        }\n        .unwrap_or_default()\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &Option<T>, env: &Env) {\n        match data.as_ref() {\n            Some(d) => self.widget.with_some(|w| w.paint(ctx, d, env)),\n            None => self.widget.with_none(|w| w.paint(ctx, &(), env)),\n        };\n    }\n}\n\nimpl<T> MaybeWidget<T> {\n    fn is_some(&self) -> bool {\n        match self {\n            Self::Some(_) => true,\n            Self::None(_) => false,\n        }\n    }\n\n    fn with_some<R, F: FnOnce(&mut WidgetPod<T, Box<dyn Widget<T>>>) -> R>(\n        &mut self,\n        f: F,\n    ) -> Option<R> {\n        match self {\n            Self::Some(widget) => Some(f(widget)),\n            Self::None(_) => {\n                // log::warn!(\"Maybe::with_some called on none value\");\n                None\n            }\n        }\n    }\n\n    fn with_none<R, F: FnOnce(&mut WidgetPod<(), Box<dyn Widget<()>>>) -> R>(\n        &mut self,\n        f: F,\n    ) -> Option<R> {\n        match self {\n            Self::None(widget) => Some(f(widget)),\n            Self::Some(_) => {\n                // log::warn!(\"Maybe::with_none called on none value\");\n                None\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/mod.rs",
    "content": "mod checkbox;\nmod dispatcher;\nmod empty;\npub mod fill_between;\npub mod icons;\nmod link;\nmod maybe;\nmod overlay;\nmod promise;\npub mod remote_image;\nmod theme;\nmod utils;\n\nuse std::{sync::Arc, time::Duration};\n\nuse druid::{\n    widget::{ControllerHost, Padding},\n    Data, Env, EventCtx, Insets, Menu, MouseButton, MouseEvent, Selector, UpdateCtx, Widget,\n};\n\npub use checkbox::Checkbox;\npub use dispatcher::ViewDispatcher;\nuse druid_shell::Cursor;\npub use empty::Empty;\npub use link::Link;\npub use maybe::Maybe;\npub use overlay::Overlay;\npub use promise::Async;\npub use remote_image::RemoteImage;\npub use theme::ThemeScope;\npub use utils::{Border, Clip, FadeOut, Logger};\n\nuse crate::{\n    controller::{ExClick, ExCursor, ExScroll, OnCommand, OnCommandAsync, OnDebounce, OnUpdate},\n    data::{AppState, SliderScrollScale},\n};\n\npub trait MyWidgetExt<T: Data>: Widget<T> + Sized + 'static {\n    #[allow(dead_code)]\n    fn log(self, label: &'static str) -> Logger<Self> {\n        Logger::new(self).with_label(label)\n    }\n\n    fn link(self) -> Link<T> {\n        Link::new(self)\n    }\n\n    fn clip<S>(self, shape: S) -> Clip<S, Self> {\n        Clip::new(shape, self)\n    }\n\n    fn padding_left(self, p: f64) -> Padding<T, Self> {\n        Padding::new(Insets::new(p, 0.0, 0.0, 0.0), self)\n    }\n\n    fn padding_right(self, p: f64) -> Padding<T, Self> {\n        Padding::new(Insets::new(0.0, 0.0, p, 0.0), self)\n    }\n\n    fn padding_horizontal(self, p: f64) -> Padding<T, Self> {\n        Padding::new(Insets::new(p, 0.0, p, 0.0), self)\n    }\n\n    fn on_debounce(\n        self,\n        duration: Duration,\n        handler: impl Fn(&mut EventCtx, &mut T, &Env) + 'static,\n    ) -> ControllerHost<Self, OnDebounce<T>> {\n        ControllerHost::new(self, OnDebounce::trailing(duration, handler))\n    }\n\n    fn on_update<F>(self, handler: F) -> ControllerHost<Self, OnUpdate<F>>\n    where\n        F: Fn(&mut UpdateCtx, &T, &T, &Env) + 'static,\n    {\n        ControllerHost::new(self, OnUpdate::new(handler))\n    }\n\n    fn on_left_click(\n        self,\n        func: impl Fn(&mut EventCtx, &MouseEvent, &mut T, &Env) + 'static,\n    ) -> ControllerHost<ControllerHost<Self, ExCursor<T>>, ExClick<T>> {\n        self.with_cursor(Cursor::Pointer)\n            .on_mouse_click(MouseButton::Left, func)\n    }\n\n    fn on_right_click(\n        self,\n        func: impl Fn(&mut EventCtx, &MouseEvent, &mut T, &Env) + 'static,\n    ) -> ControllerHost<Self, ExClick<T>> {\n        self.on_mouse_click(MouseButton::Right, func)\n    }\n\n    fn on_mouse_click(\n        self,\n        button: MouseButton,\n        func: impl Fn(&mut EventCtx, &MouseEvent, &mut T, &Env) + 'static,\n    ) -> ControllerHost<Self, ExClick<T>> {\n        ControllerHost::new(self, ExClick::new(Some(button), func))\n    }\n\n    fn on_scroll(\n        self,\n        scale_picker: impl Fn(&mut T) -> &SliderScrollScale + 'static,\n        action: impl Fn(&mut EventCtx, &mut T, &Env, f64) + 'static,\n    ) -> ControllerHost<Self, ExScroll<T>> {\n        ControllerHost::new(self, ExScroll::new(scale_picker, action))\n    }\n\n    fn with_cursor(self, cursor: Cursor) -> ControllerHost<Self, ExCursor<T>> {\n        ControllerHost::new(self, ExCursor::new(cursor))\n    }\n\n    fn on_command<U, F>(\n        self,\n        selector: Selector<U>,\n        func: F,\n    ) -> ControllerHost<Self, OnCommand<U, F>>\n    where\n        U: 'static,\n        F: Fn(&mut EventCtx, &U, &mut T),\n    {\n        ControllerHost::new(self, OnCommand::new(selector, func))\n    }\n\n    fn on_command_async<U: Data + Send, V: Data + Send>(\n        self,\n        selector: Selector<U>,\n        request: impl Fn(U) -> V + Sync + Send + 'static,\n        preflight: impl Fn(&mut EventCtx, &mut T, U) + 'static,\n        response: impl Fn(&mut EventCtx, &mut T, (U, V)) + 'static,\n    ) -> OnCommandAsync<Self, T, U, V> {\n        OnCommandAsync::new(\n            self,\n            selector,\n            Box::new(preflight),\n            Arc::new(request),\n            Box::new(response),\n        )\n    }\n\n    fn context_menu(\n        self,\n        func: impl Fn(&T) -> Menu<AppState> + 'static,\n    ) -> ControllerHost<Self, ExClick<T>> {\n        self.on_right_click(move |ctx, event, data, _env| {\n            ctx.show_context_menu(func(data), event.window_pos);\n        })\n    }\n}\n\nimpl<T: Data, W: Widget<T> + 'static> MyWidgetExt<T> for W {}\n"
  },
  {
    "path": "psst-gui/src/widget/overlay.rs",
    "content": "use druid::{widget::prelude::*, Data, Point, Vec2, WidgetPod};\n\npub enum OverlayPosition {\n    Bottom,\n}\n\npub struct Overlay<T, W, O> {\n    inner: W,\n    overlay: WidgetPod<T, O>,\n    position: OverlayPosition,\n}\n\nimpl<T, W, O> Overlay<T, W, O>\nwhere\n    O: Widget<T>,\n{\n    pub fn bottom(inner: W, overlay: O) -> Self {\n        Self {\n            inner,\n            overlay: WidgetPod::new(overlay),\n            position: OverlayPosition::Bottom,\n        }\n    }\n}\n\nimpl<T, W, O> Widget<T> for Overlay<T, W, O>\nwhere\n    T: Data,\n    W: Widget<T>,\n    O: Widget<T>,\n{\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.inner.event(ctx, event, data, env);\n        self.overlay.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.inner.lifecycle(ctx, event, data, env);\n        self.overlay.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        self.inner.update(ctx, old_data, data, env);\n        self.overlay.update(ctx, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let inner_size = self.inner.layout(ctx, bc, data, env);\n        let over_size = self.overlay.layout(ctx, bc, data, env);\n        let pos = match self.position {\n            OverlayPosition::Bottom => {\n                Point::ORIGIN + Vec2::new(0.0, inner_size.height - over_size.height)\n            }\n        };\n        self.overlay.set_origin(ctx, pos);\n        inner_size\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        self.inner.paint(ctx, data, env);\n        self.overlay.paint(ctx, data, env);\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/promise.rs",
    "content": "use druid::{widget::prelude::*, Data, Point, WidgetExt, WidgetPod};\n\nuse crate::data::{Promise, PromiseState};\n\npub struct Async<T, D, E> {\n    def_maker: Box<dyn Fn() -> Box<dyn Widget<D>>>,\n    res_maker: Box<dyn Fn() -> Box<dyn Widget<T>>>,\n    err_maker: Box<dyn Fn() -> Box<dyn Widget<E>>>,\n    widget: PromiseWidget<T, D, E>,\n}\n\n#[allow(clippy::large_enum_variant)]\nenum PromiseWidget<T, D, E> {\n    Empty,\n    Deferred(WidgetPod<D, Box<dyn Widget<D>>>),\n    Resolved(WidgetPod<T, Box<dyn Widget<T>>>),\n    Rejected(WidgetPod<E, Box<dyn Widget<E>>>),\n}\n\nimpl<D: Data, T: Data, E: Data> Async<T, D, E> {\n    pub fn new<WD, WT, WE>(\n        def_maker: impl Fn() -> WD + 'static,\n        res_maker: impl Fn() -> WT + 'static,\n        err_maker: impl Fn() -> WE + 'static,\n    ) -> Self\n    where\n        WD: Widget<D> + 'static,\n        WT: Widget<T> + 'static,\n        WE: Widget<E> + 'static,\n    {\n        Self {\n            def_maker: Box::new(move || def_maker().boxed()),\n            res_maker: Box::new(move || res_maker().boxed()),\n            err_maker: Box::new(move || err_maker().boxed()),\n            widget: PromiseWidget::Empty,\n        }\n    }\n\n    fn rebuild_widget(&mut self, state: PromiseState) {\n        self.widget = match state {\n            PromiseState::Empty => PromiseWidget::Empty,\n            PromiseState::Deferred => PromiseWidget::Deferred(WidgetPod::new((self.def_maker)())),\n            PromiseState::Resolved => PromiseWidget::Resolved(WidgetPod::new((self.res_maker)())),\n            PromiseState::Rejected => PromiseWidget::Rejected(WidgetPod::new((self.err_maker)())),\n        };\n    }\n}\n\nimpl<D: Data, T: Data, E: Data> Widget<Promise<T, D, E>> for Async<T, D, E> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut Promise<T, D, E>, env: &Env) {\n        if data.state() == self.widget.state() {\n            match data {\n                Promise::Empty => {}\n                Promise::Deferred { def } => {\n                    self.widget.with_deferred(|w| w.event(ctx, event, def, env));\n                }\n                Promise::Resolved { val, .. } => {\n                    self.widget.with_resolved(|w| w.event(ctx, event, val, env));\n                }\n                Promise::Rejected { err, .. } => {\n                    self.widget.with_rejected(|w| w.event(ctx, event, err, env));\n                }\n            };\n        }\n    }\n\n    fn lifecycle(\n        &mut self,\n        ctx: &mut LifeCycleCtx,\n        event: &LifeCycle,\n        data: &Promise<T, D, E>,\n        env: &Env,\n    ) {\n        if data.state() != self.widget.state() {\n            // Possible if getting lifecycle after an event that changed the data,\n            // or on WidgetAdded.\n            self.rebuild_widget(data.state());\n        }\n        assert_eq!(data.state(), self.widget.state(), \"{event:?}\");\n        match data {\n            Promise::Empty => {}\n            Promise::Deferred { def } => {\n                self.widget\n                    .with_deferred(|w| w.lifecycle(ctx, event, def, env));\n            }\n            Promise::Resolved { val, .. } => {\n                self.widget\n                    .with_resolved(|w| w.lifecycle(ctx, event, val, env));\n            }\n            Promise::Rejected { err, .. } => {\n                self.widget\n                    .with_rejected(|w| w.lifecycle(ctx, event, err, env));\n            }\n        };\n    }\n\n    fn update(\n        &mut self,\n        ctx: &mut UpdateCtx,\n        old_data: &Promise<T, D, E>,\n        data: &Promise<T, D, E>,\n        env: &Env,\n    ) {\n        if old_data.state() != data.state() {\n            self.rebuild_widget(data.state());\n            ctx.children_changed();\n        } else {\n            match data {\n                Promise::Empty => {}\n                Promise::Deferred { def } => {\n                    self.widget.with_deferred(|w| w.update(ctx, def, env));\n                }\n                Promise::Resolved { val, .. } => {\n                    self.widget.with_resolved(|w| w.update(ctx, val, env));\n                }\n                Promise::Rejected { err, .. } => {\n                    self.widget.with_rejected(|w| w.update(ctx, err, env));\n                }\n            };\n        }\n    }\n\n    fn layout(\n        &mut self,\n        ctx: &mut LayoutCtx,\n        bc: &BoxConstraints,\n        data: &Promise<T, D, E>,\n        env: &Env,\n    ) -> Size {\n        match data {\n            Promise::Empty => None,\n            Promise::Deferred { def } => self.widget.with_deferred(|w| {\n                let size = w.layout(ctx, bc, def, env);\n                w.set_origin(ctx, Point::ORIGIN);\n                size\n            }),\n            Promise::Resolved { val, .. } => self.widget.with_resolved(|w| {\n                let size = w.layout(ctx, bc, val, env);\n                w.set_origin(ctx, Point::ORIGIN);\n                size\n            }),\n            Promise::Rejected { err, .. } => self.widget.with_rejected(|w| {\n                let size = w.layout(ctx, bc, err, env);\n                w.set_origin(ctx, Point::ORIGIN);\n                size\n            }),\n        }\n        .unwrap_or_default()\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &Promise<T, D, E>, env: &Env) {\n        match data {\n            Promise::Empty => {}\n            Promise::Deferred { def } => {\n                self.widget.with_deferred(|w| w.paint(ctx, def, env));\n            }\n            Promise::Resolved { val, .. } => {\n                self.widget.with_resolved(|w| w.paint(ctx, val, env));\n            }\n            Promise::Rejected { err, .. } => {\n                self.widget.with_rejected(|w| w.paint(ctx, err, env));\n            }\n        };\n    }\n}\n\nimpl<T, D, E> PromiseWidget<T, D, E> {\n    fn state(&self) -> PromiseState {\n        match self {\n            Self::Empty => PromiseState::Empty,\n            Self::Deferred(_) => PromiseState::Deferred,\n            Self::Resolved(_) => PromiseState::Resolved,\n            Self::Rejected(_) => PromiseState::Rejected,\n        }\n    }\n\n    fn with_deferred<R, F: FnOnce(&mut WidgetPod<D, Box<dyn Widget<D>>>) -> R>(\n        &mut self,\n        f: F,\n    ) -> Option<R> {\n        if let Self::Deferred(widget) = self {\n            Some(f(widget))\n        } else {\n            None\n        }\n    }\n\n    fn with_resolved<R, F: FnOnce(&mut WidgetPod<T, Box<dyn Widget<T>>>) -> R>(\n        &mut self,\n        f: F,\n    ) -> Option<R> {\n        if let Self::Resolved(widget) = self {\n            Some(f(widget))\n        } else {\n            None\n        }\n    }\n\n    fn with_rejected<R, F: FnOnce(&mut WidgetPod<E, Box<dyn Widget<E>>>) -> R>(\n        &mut self,\n        f: F,\n    ) -> Option<R> {\n        if let Self::Rejected(widget) = self {\n            Some(f(widget))\n        } else {\n            None\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/remote_image.rs",
    "content": "use std::sync::Arc;\n\nuse druid::{\n    widget::{prelude::*, FillStrat, Image},\n    Data, ImageBuf, Point, Selector, WidgetPod,\n};\n\npub const REQUEST_DATA: Selector<Arc<str>> = Selector::new(\"remote-image.request-data\");\npub const PROVIDE_DATA: Selector<ImagePayload> = Selector::new(\"remote-image.provide-data\");\n\n#[derive(Clone)]\npub struct ImagePayload {\n    pub location: Arc<str>,\n    pub image_buf: ImageBuf,\n}\n\npub struct RemoteImage<T> {\n    placeholder: WidgetPod<T, Box<dyn Widget<T>>>,\n    image: Option<WidgetPod<T, Image>>,\n    locator: Box<dyn Fn(&T, &Env) -> Option<Arc<str>>>,\n    location: Option<Arc<str>>,\n}\n\nimpl<T: Data> RemoteImage<T> {\n    pub fn new(\n        placeholder: impl Widget<T> + 'static,\n        locator: impl Fn(&T, &Env) -> Option<Arc<str>> + 'static,\n    ) -> Self {\n        Self {\n            placeholder: WidgetPod::new(placeholder).boxed(),\n            locator: Box::new(locator),\n            location: None,\n            image: None,\n        }\n    }\n}\n\nimpl<T: Data> Widget<T> for RemoteImage<T> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        if let Event::Command(cmd) = event {\n            if let Some(payload) = cmd.get(PROVIDE_DATA) {\n                if Some(&payload.location) == self.location.as_ref() {\n                    self.image.replace(WidgetPod::new(\n                        Image::new(payload.image_buf.clone()).fill_mode(FillStrat::Cover),\n                    ));\n                    ctx.children_changed();\n                }\n                return;\n            }\n        }\n        if let Some(image) = self.image.as_mut() {\n            image.event(ctx, event, data, env);\n        } else {\n            self.placeholder.event(ctx, event, data, env);\n        }\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        if let LifeCycle::WidgetAdded = event {\n            let location = (self.locator)(data, env);\n            self.image = None;\n            self.location.clone_from(&location);\n            if let Some(location) = location {\n                ctx.submit_command(REQUEST_DATA.with(location).to(ctx.widget_id()));\n            }\n        }\n        if let Some(image) = self.image.as_mut() {\n            image.lifecycle(ctx, event, data, env);\n        } else {\n            self.placeholder.lifecycle(ctx, event, data, env);\n        }\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) {\n        let location = (self.locator)(data, env);\n        if location != self.location {\n            self.image = None;\n            self.location.clone_from(&location);\n            if let Some(location) = location {\n                ctx.submit_command(REQUEST_DATA.with(location).to(ctx.widget_id()));\n            }\n            ctx.children_changed();\n        }\n        if let Some(image) = self.image.as_mut() {\n            image.update(ctx, data, env);\n        } else {\n            self.placeholder.update(ctx, data, env);\n        }\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        if let Some(image) = self.image.as_mut() {\n            let size = image.layout(ctx, bc, data, env);\n            image.set_origin(ctx, Point::ORIGIN);\n            size\n        } else {\n            let size = self.placeholder.layout(ctx, bc, data, env);\n            self.placeholder.set_origin(ctx, Point::ORIGIN);\n            size\n        }\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        if let Some(image) = self.image.as_mut() {\n            image.paint(ctx, data, env)\n        } else {\n            self.placeholder.paint(ctx, data, env)\n        }\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/theme.rs",
    "content": "use crate::{data::AppState, ui::theme};\nuse druid::widget::prelude::*;\n\npub struct ThemeScope<W> {\n    inner: W,\n    cached_env: Option<Env>,\n}\n\nimpl<W> ThemeScope<W> {\n    pub fn new(inner: W) -> Self {\n        Self {\n            inner,\n            cached_env: None,\n        }\n    }\n\n    fn set_env(&mut self, data: &AppState, outer_env: &Env) {\n        let mut themed_env = outer_env.clone();\n        theme::setup(&mut themed_env, data);\n        self.cached_env.replace(themed_env);\n    }\n}\n\nimpl<W: Widget<AppState>> Widget<AppState> for ThemeScope<W> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut AppState, env: &Env) {\n        self.inner\n            .event(ctx, event, data, self.cached_env.as_ref().unwrap_or(env))\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &AppState, env: &Env) {\n        if let LifeCycle::WidgetAdded = &event {\n            self.set_env(data, env);\n        }\n        self.inner\n            .lifecycle(ctx, event, data, self.cached_env.as_ref().unwrap_or(env))\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &AppState, data: &AppState, env: &Env) {\n        if !data.config.theme.same(&old_data.config.theme) {\n            self.set_env(data, env);\n            ctx.request_layout();\n            ctx.request_paint();\n        }\n        self.inner\n            .update(ctx, old_data, data, self.cached_env.as_ref().unwrap_or(env));\n    }\n\n    fn layout(\n        &mut self,\n        ctx: &mut LayoutCtx,\n        bc: &BoxConstraints,\n        data: &AppState,\n        env: &Env,\n    ) -> Size {\n        self.inner\n            .layout(ctx, bc, data, self.cached_env.as_ref().unwrap_or(env))\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &AppState, env: &Env) {\n        self.inner\n            .paint(ctx, data, self.cached_env.as_ref().unwrap_or(env));\n    }\n}\n"
  },
  {
    "path": "psst-gui/src/widget/utils.rs",
    "content": "use druid::{\n    kurbo::{Line, Shape},\n    widget::{prelude::*, Axis, BackgroundBrush, Painter},\n    Color, Data, KeyOrValue,\n};\n\npub struct FadeOut<W> {\n    inner: W,\n    axis: Axis,\n    limit: KeyOrValue<f64>,\n    color: KeyOrValue<Color>,\n    over_limit: bool,\n}\n\nimpl<W> FadeOut<W> {\n    // const FADE_LENGTH_MAX: f64 = 32.0;\n\n    pub fn new(inner: W, axis: Axis, limit: KeyOrValue<f64>) -> Self {\n        Self {\n            inner,\n            axis,\n            limit,\n            color: Color::BLACK.into(),\n            over_limit: false,\n        }\n    }\n\n    pub fn bottom(inner: W, height: impl Into<KeyOrValue<f64>>) -> Self {\n        Self::new(inner, Axis::Vertical, height.into())\n    }\n\n    pub fn with_color(mut self, color: impl Into<KeyOrValue<Color>>) -> Self {\n        self.color = color.into();\n        self\n    }\n}\n\nimpl<T, W> Widget<T> for FadeOut<W>\nwhere\n    T: Data,\n    W: Widget<T>,\n{\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.inner.event(ctx, event, data, env);\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.inner.lifecycle(ctx, event, data, env);\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        self.inner.update(ctx, old_data, data, env);\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let size = self.inner.layout(\n            ctx,\n            &BoxConstraints::new(\n                bc.min(),\n                match self.axis {\n                    // Unbounded with.\n                    Axis::Horizontal => Size::new(f64::INFINITY, bc.max().height),\n                    // Unbounded height.\n                    Axis::Vertical => Size::new(bc.max().width, f64::INFINITY),\n                },\n            ),\n            data,\n            env,\n        );\n        let limit = self.limit.resolve(env);\n        self.over_limit = self.axis.major(size) > limit;\n        if self.over_limit {\n            Size::from(self.axis.pack(limit, self.axis.minor(size)))\n        } else {\n            size\n        }\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        let size = ctx.size();\n        let rect = size.to_rect();\n        if self.over_limit {\n            // Clip and paint inner widget.\n            ctx.with_save(|ctx| {\n                ctx.clip(rect);\n                self.inner.paint(ctx, data, env);\n            });\n            // Paint the gradient.\n            // let gradient_rect = match self.axis {\n            //     Axis::Horizontal => Rect::new(\n            //         0.0f64.max(size.width - Self::FADE_LENGTH_MAX),\n            //         0.0,\n            //         size.width,\n            //         size.height,\n            //     ),\n            //     Axis::Vertical => Rect::new(\n            //         0.0,\n            //         0.0f64.max(size.height - Self::FADE_LENGTH_MAX),\n            //         size.width,\n            //         size.height,\n            //     ),\n            // };\n            // let (start, end) = match self.axis {\n            //     Axis::Horizontal => (UnitPoint::LEFT, UnitPoint::RIGHT),\n            //     Axis::Vertical => (UnitPoint::TOP, UnitPoint::BOTTOM),\n            // };\n            // ctx.fill(\n            //     &gradient_rect,\n            //     &LinearGradient::new(\n            //         start,\n            //         end,\n            //         (\n            //             self.color.resolve(env).with_alpha(0.0),\n            //             self.color.resolve(env),\n            //         ),\n            //     ),\n            // );\n        } else {\n            self.inner.paint(ctx, data, env);\n        }\n    }\n\n    fn id(&self) -> Option<WidgetId> {\n        self.inner.id()\n    }\n}\n\npub struct Clip<S, W> {\n    shape: S,\n    inner: W,\n}\n\nimpl<S, W> Clip<S, W> {\n    pub fn new(shape: S, inner: W) -> Self {\n        Self { shape, inner }\n    }\n}\n\nimpl<T: Data, S: Shape, W: Widget<T>> Widget<T> for Clip<S, W> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        self.inner.event(ctx, event, data, env)\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        self.inner.lifecycle(ctx, event, data, env)\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        self.inner.update(ctx, old_data, data, env)\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        let size = self.inner.layout(ctx, bc, data, env);\n        let bbox = self.shape.bounding_box().size();\n        Size::new(size.width.min(bbox.width), size.height.min(bbox.height))\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        ctx.with_save(|ctx| {\n            ctx.clip(&self.shape);\n            self.inner.paint(ctx, data, env);\n        });\n    }\n\n    fn id(&self) -> Option<WidgetId> {\n        self.inner.id()\n    }\n}\n\npub enum Border {\n    Top,\n    Bottom,\n}\n\nimpl Border {\n    pub fn with_color<T: Data>(\n        self,\n        color: impl Into<KeyOrValue<Color>>,\n    ) -> impl Into<BackgroundBrush<T>> {\n        let color = color.into();\n\n        Painter::new(move |ctx, _, env| {\n            let h = 1.0;\n            let y = match self {\n                Self::Top => h / 2.0,\n                Self::Bottom => ctx.size().height - h / 2.0,\n            };\n            let line = Line::new((0.0, y), (ctx.size().width, y));\n            ctx.stroke(line, &color.resolve(env), h);\n        })\n    }\n}\n\npub struct Logger<W> {\n    inner: W,\n    label: &'static str,\n    event: bool,\n    lifecycle: bool,\n    update: bool,\n    layout: bool,\n    paint: bool,\n}\n\n#[allow(dead_code)]\nimpl<W> Logger<W> {\n    pub fn new(inner: W) -> Self {\n        Self {\n            inner,\n            label: \"logger\",\n            event: false,\n            lifecycle: false,\n            update: false,\n            layout: false,\n            paint: false,\n        }\n    }\n\n    pub fn with_label(mut self, title: &'static str) -> Self {\n        self.label = title;\n        self\n    }\n\n    pub fn with_event(mut self) -> Self {\n        self.event = true;\n        self\n    }\n\n    pub fn with_lifecycle(mut self) -> Self {\n        self.lifecycle = true;\n        self\n    }\n\n    pub fn with_update(mut self) -> Self {\n        self.update = true;\n        self\n    }\n\n    pub fn with_layout(mut self) -> Self {\n        self.layout = true;\n        self\n    }\n\n    pub fn with_paint(mut self) -> Self {\n        self.paint = true;\n        self\n    }\n}\n\nimpl<T: Data, W: Widget<T>> Widget<T> for Logger<W> {\n    fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {\n        if self.event {\n            log::info!(\"{:?} event: {:?}\", self.label, event);\n        }\n        self.inner.event(ctx, event, data, env)\n    }\n\n    fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {\n        if self.lifecycle {\n            log::info!(\"{:?} lifecycle: {:?}\", self.label, event);\n        }\n        self.inner.lifecycle(ctx, event, data, env)\n    }\n\n    fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {\n        if self.update {\n            log::info!(\"{:?} update\", self.label);\n        }\n        self.inner.update(ctx, old_data, data, env)\n    }\n\n    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {\n        if self.layout {\n            log::info!(\"{:?} layout\", self.label);\n        }\n        self.inner.layout(ctx, bc, data, env)\n    }\n\n    fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {\n        if self.paint {\n            log::info!(\"{:?} paint\", self.label);\n        }\n        self.inner.paint(ctx, data, env)\n    }\n\n    fn id(&self) -> Option<WidgetId> {\n        self.inner.id()\n    }\n}\n"
  }
]