diff --git a/.github/workflows/release-v2.3.yml b/.github/workflows/release-v2.3.yml deleted file mode 100644 index a0abbc6..0000000 --- a/.github/workflows/release-v2.3.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Publish v2.3 release - -on: - push: - branches: - - main - -permissions: - contents: write - -jobs: - release: - if: startsWith(github.event.head_commit.message, 'Release Ryotunes v2.3.0') - runs-on: ubuntu-24.04 - container: - image: archlinux:base-devel - - steps: - - name: Bootstrap checkout runtime - run: | - pacman -Syu --noconfirm --needed git nodejs ca-certificates - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Arch build dependencies - run: | - pacman -S --noconfirm --needed rustup pnpm python libarchive zstd zip github-cli webkit2gtk-4.1 libappindicator-gtk3 mpv openssl librsvg desktop-file-utils hicolor-icon-theme xdg-utils appmenu-gtk-module xdotool - - - name: Prepare unprivileged builder - run: | - id builder >/dev/null 2>&1 || useradd -m -s /bin/bash builder - chown -R builder:builder "$GITHUB_WORKSPACE" - echo "BUILDER_HOME=$(getent passwd builder | cut -d: -f6)" >> "$GITHUB_ENV" - - - name: Install Rust toolchain and Tauri CLI - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" bash -lc ' - rustup default stable - rustup component add rustfmt - cargo install tauri-cli --locked --version "^2" - ' - - - name: Install frontend dependencies - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - cd '$GITHUB_WORKSPACE/ui' - pnpm install --frozen-lockfile - " - - - name: Validate v2.3 source - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - cd '$GITHUB_WORKSPACE' - cargo fmt --all - RYOTUNES_STATIC_ONLY=1 ./scripts/release-check.sh - - cd ui - pnpm check - pnpm build - cd .. - - cargo fetch --locked - - # This one test intentionally drives a real libmpv instance and an invalid - # rubberband filter. It passes on normal Ubuntu CI and real Ryoku hardware, - # but can block indefinitely with Arch's newer libmpv inside a nested - # GitHub Actions container. Keep it in normal CI; do not let packaging hang. - CARGO_NET_OFFLINE=true cargo test --workspace --locked --offline -- --skip mpv_keeps_the_gain_through_pitch_changes_and_failures - - CARGO_NET_OFFLINE=true cargo check --workspace --locked --offline - " - - - name: Build native release binaries - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" CARGO_NET_OFFLINE=true bash -lc " - cd '$GITHUB_WORKSPACE' - cargo tauri build --no-bundle - cargo build --release --locked --offline --package sync-server - test -x target/release/ryotunes - test -x target/release/ryotunes-sync - " - - - name: Build release assets - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - set -euo pipefail - cd '$GITHUB_WORKSPACE' - chmod +x scripts/build-replacement-package.sh scripts/make-source-release.sh scripts/release-check.sh scripts/ryoku-window-rule.sh - rm -rf dist - mkdir -p dist - ./scripts/build-replacement-package.sh dist - RYOTUNES_STATIC_ONLY=1 ./scripts/make-source-release.sh dist/ryotunes-v2.3.0-final-source.tar.gz - cd dist - sha256sum ryotunes-v2.3.0-final-source.tar.gz >> SHA256SUMS-v2.3.0.txt - sort -u SHA256SUMS-v2.3.0.txt -o SHA256SUMS-v2.3.0.txt - " - - - name: Inspect release assets - run: | - ls -lh dist - cat dist/SHA256SUMS-v2.3.0.txt - bsdtar -tf dist/ryotunes-v2.3-2.3.0-1-x86_64.pkg.tar.zst | grep -E 'usr/(bin|lib|share)' | head -80 - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - assets=( - dist/ryotunes-v2.3-2.3.0-1-x86_64.pkg.tar.zst - dist/Ryotunes-v2.3-Ryoku-x86_64.zip - dist/ryotunes-v2.3.0-linux-x86_64.tar.zst - dist/ryotunes-v2.3.0-final-source.tar.gz - dist/SHA256SUMS-v2.3.0.txt - ) - - if gh release view v2.3.0 --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload v2.3.0 "${assets[@]}" --clobber --repo "$GITHUB_REPOSITORY" - gh release edit v2.3.0 --title "Ryotunes v2.3.0" --notes-file RELEASE_NOTES.md --latest --repo "$GITHUB_REPOSITORY" - else - gh release create v2.3.0 "${assets[@]}" --target "$GITHUB_SHA" --title "Ryotunes v2.3.0" --notes-file RELEASE_NOTES.md --latest --repo "$GITHUB_REPOSITORY" - fi diff --git a/.github/workflows/release-v2.4.yml b/.github/workflows/release-v2.4.yml deleted file mode 100644 index 451d3ee..0000000 --- a/.github/workflows/release-v2.4.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Publish v2.4.1 release - -on: - push: - branches: - - main - -permissions: - contents: write - -jobs: - release: - if: startsWith(github.event.head_commit.message, 'Release Ryotunes v2.4.1') - runs-on: ubuntu-24.04 - container: - image: archlinux:base-devel - - steps: - - name: Bootstrap checkout runtime - run: | - pacman -Syu --noconfirm --needed git nodejs ca-certificates - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Arch build dependencies - run: | - pacman -S --noconfirm --needed rustup pnpm python libarchive zstd zip github-cli webkit2gtk-4.1 libappindicator-gtk3 mpv openssl librsvg desktop-file-utils hicolor-icon-theme xdg-utils appmenu-gtk-module xdotool - - - name: Prepare unprivileged builder - run: | - id builder >/dev/null 2>&1 || useradd -m -s /bin/bash builder - chown -R builder:builder "$GITHUB_WORKSPACE" - echo "BUILDER_HOME=$(getent passwd builder | cut -d: -f6)" >> "$GITHUB_ENV" - - - name: Install Rust toolchain and Tauri CLI - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" bash -lc ' - rustup default stable - rustup component add rustfmt - cargo install tauri-cli --locked --version "^2" - ' - - - name: Install frontend dependencies - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - cd '$GITHUB_WORKSPACE/ui' - pnpm install --frozen-lockfile - " - - - name: Validate v2.4.1 source - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - cd '$GITHUB_WORKSPACE' - cargo fmt --all - RYOTUNES_STATIC_ONLY=1 ./scripts/release-check.sh - - cd ui - pnpm check - pnpm build - cd .. - - cargo fetch --locked - - # This one test intentionally drives a real libmpv instance and an invalid - # rubberband filter. It passes on normal Ubuntu CI and real Ryoku hardware, - # but can block indefinitely with Arch's newer libmpv inside a nested - # GitHub Actions container. Keep it in normal CI; do not let packaging hang. - CARGO_NET_OFFLINE=true cargo test --workspace --locked --offline -- --skip mpv_keeps_the_gain_through_pitch_changes_and_failures - - CARGO_NET_OFFLINE=true cargo check --workspace --locked --offline - " - - - name: Build native release binaries - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" CARGO_NET_OFFLINE=true bash -lc " - cd '$GITHUB_WORKSPACE' - cargo tauri build --no-bundle - cargo build --release --locked --offline --package sync-server - test -x target/release/ryotunes - test -x target/release/ryotunes-sync - " - - - name: Build release assets - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - set -euo pipefail - cd '$GITHUB_WORKSPACE' - chmod +x scripts/build-replacement-package.sh scripts/make-source-release.sh scripts/release-check.sh scripts/ryoku-window-rule.sh - rm -rf dist - mkdir -p dist - ./scripts/build-replacement-package.sh dist - RYOTUNES_STATIC_ONLY=1 ./scripts/make-source-release.sh dist/ryotunes-v2.4.1-final-source.tar.gz - cd dist - sha256sum ryotunes-v2.4.1-final-source.tar.gz >> SHA256SUMS-v2.4.1.txt - sort -u SHA256SUMS-v2.4.1.txt -o SHA256SUMS-v2.4.1.txt - " - - - name: Inspect release assets - run: | - ls -lh dist - cat dist/SHA256SUMS-v2.4.1.txt - bsdtar -tf dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst | grep -E 'usr/(bin|lib|share)' | head -80 - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - assets=( - dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst - dist/Ryotunes-v2.4-Ryoku-x86_64.zip - dist/ryotunes-v2.4.1-linux-x86_64.tar.zst - dist/ryotunes-v2.4.1-final-source.tar.gz - dist/SHA256SUMS-v2.4.1.txt - ) - - if gh release view v2.4.1 --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload v2.4.1 "${assets[@]}" --clobber --repo "$GITHUB_REPOSITORY" - gh release edit v2.4.1 --title "Ryotunes v2.4.1" --notes-file RELEASE_NOTES.md --latest --repo "$GITHUB_REPOSITORY" - else - gh release create v2.4.1 "${assets[@]}" --target "$GITHUB_SHA" --title "Ryotunes v2.4.1" --notes-file RELEASE_NOTES.md --latest --repo "$GITHUB_REPOSITORY" - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f190e09 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,298 @@ +name: Release + +# Ryotunes releases itself on every push to the default branch. One pipeline: +# 1. release: work out the next v1 version from the published tags (the +# version ledger, scripts/next-version.sh), sync every manifest to it, +# verify the tree builds and the release invariants hold, then tag that +# exact pushed source. A later source push never invalidates an earlier +# run; manifests are fast-forwarded back to the default branch only when +# it has not moved in the meantime. +# 2. package: an unprivileged makepkg in an Arch container builds the reserved +# tag's exact source into ryotunes--1-x86_64.pkg.tar.zst (+ .sha256). +# 3. publish: a normal (latest, non-draft) GitHub release carrying the package, +# its sha256, a reproducible source tarball + sha256, and notes built from +# the changelog plus install instructions. Ryoku's update/doctor read this +# release directly; there is no downstream repository_dispatch. +# +# The version bump is pushed with GITHUB_TOKEN, and a GITHUB_TOKEN push never +# retriggers a workflow, so there is no bot loop and nothing relies on a +# token-pushed tag firing a second workflow (the historic tag-trigger trap). +# A failed package/publish leaves a reserved tag. Re-run the failed workflow or +# dispatch that tag to finish publishing without minting another version. +on: + push: + branches: ['**'] + workflow_dispatch: + inputs: + tag: + description: Existing tag to rebuild and re-publish, e.g. v1.0.2 + required: true + +permissions: + contents: write + +# Queue pushes instead of replacing the one pending run (GitHub's default). +# queue:max retains up to GitHub's 100-run limit; never cancel an active release. +concurrency: + group: release + cancel-in-progress: false + queue: max + +jobs: + release: + name: Verify and reserve the version + if: github.event_name == 'workflow_dispatch' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + outputs: + action: ${{ steps.plan.outputs.action }} + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.sha }} + fetch-depth: 0 + + # Decide what this run does: + # release - a fresh push: compute + reserve the next version. + # republish - an existing tag: rebuild and re-upload (manual dispatch, or + # automatic resume when a reserved tag has no complete release). + # skip - this exact source is already fully released. + - name: Plan + id: plan + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + tag="$INPUT_TAG" + [[ "$tag" =~ ^v1\.(0|[1-9][0-9]*)\.[0-9]$ ]] \ + || { echo "::error::expected a canonical v1 release tag"; exit 1; } + git rev-parse -q --verify "refs/tags/$tag" >/dev/null \ + || { echo "::error::tag $tag does not exist"; exit 1; } + ver="${tag#v}" + cv="$(sed -n 's/^version = "\([0-9.]*\)"/\1/p' Cargo.toml | head -1)" + [[ "$cv" == "$ver" ]] \ + || { echo "::error::tag $tag tree is version $cv, not $ver"; exit 1; } + { + echo "action=republish" + echo "version=$ver" + echo "tag=$tag" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Idempotency by source SHA: was this pushed commit already reserved as a + # v1 tag (the tag sits on it, or on the release commit made on top of it)? + src="$(git rev-parse HEAD)" + reserved="" + for t in $(git tag -l 'v1.*'); do + [[ "$t" =~ ^v1\.(0|[1-9][0-9]*)\.[0-9]$ ]] || continue + tc="$(git rev-parse "$t^{commit}")" + tp="$(git rev-parse "$t^{commit}^1" 2>/dev/null || echo none)" + if [[ "$tc" == "$src" || "$tp" == "$src" ]]; then reserved="$t"; break; fi + done + if [[ -n "$reserved" ]]; then + ver="${reserved#v}" + complete="$(gh release view "$reserved" --json isDraft,isPrerelease,assets 2>/dev/null || true)" + if RELEASE_JSON="$complete" VERSION="$ver" python3 - <<'PY' + import json, os, sys + try: + r = json.loads(os.environ["RELEASE_JSON"]) + v = os.environ["VERSION"] + package, source = f"ryotunes-{v}-1-x86_64.pkg.tar.zst", f"ryotunes-{v}.tar.gz" + required = {package, package + ".sha256", source, source + ".sha256"} + complete = not r["isDraft"] and not r["isPrerelease"] and required <= {a["name"] for a in r["assets"]} + except (ValueError, KeyError): + complete = False + sys.exit(0 if complete else 1) + PY + then + echo "action=skip" >> "$GITHUB_OUTPUT" + echo "$reserved is already published; nothing to do" + else + echo "action=republish" >> "$GITHUB_OUTPUT" + echo "$reserved is reserved but its release is incomplete; resuming" + fi + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "tag=$reserved" >> "$GITHUB_OUTPUT" + exit 0 + fi + + ver="$(scripts/next-version.sh)" + { + echo "action=release" + echo "version=$ver" + echo "tag=v$ver" + } >> "$GITHUB_OUTPUT" + echo "reserving v$ver from $src" + + - name: Check out the tag to re-publish + if: steps.plan.outputs.action == 'republish' + run: git checkout --force --detach "${{ steps.plan.outputs.tag }}" + + - name: Sync every manifest to the new version + if: steps.plan.outputs.action == 'release' + run: scripts/sync-version.sh "${{ steps.plan.outputs.version }}" --release + + - name: Install Linux dependencies + if: steps.plan.outputs.action != 'skip' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libmpv-dev + + - uses: dtolnay/rust-toolchain@stable + if: steps.plan.outputs.action != 'skip' + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + if: steps.plan.outputs.action != 'skip' + - uses: pnpm/action-setup@v4 + if: steps.plan.outputs.action != 'skip' + with: + version: 11 + - uses: actions/setup-node@v4 + if: steps.plan.outputs.action != 'skip' + with: + node-version: 22 + cache: pnpm + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Release invariants + if: steps.plan.outputs.action != 'skip' + run: python3 scripts/check-release-invariants.py + + - name: Build the web UI + if: steps.plan.outputs.action != 'skip' + working-directory: ui + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Build every binary + if: steps.plan.outputs.action != 'skip' + run: cargo build --release --locked --package ryotunes --package ryotunesd --package ryotunes-cli + + - name: Skins validate + if: steps.plan.outputs.action != 'skip' + run: | + for d in skins/*/; do target/release/ryotunes-cli skin check "$d"; done + + - name: Unit tests + if: steps.plan.outputs.action != 'skip' + run: cargo test --workspace --locked --exclude ryotunes + + # Reserve the exact pushed source, even if a later push has moved the + # default branch while this build ran. Never force-push a user's history. + - name: Reserve the version + if: steps.plan.outputs.action == 'release' + env: + TAG: ${{ steps.plan.outputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Cargo.toml Cargo.lock src-tauri/tauri.conf.json ui/package.json client/version packaging/arch/PKGBUILD packaging/linux/dev.ryoku.ryotunes.metainfo.xml CHANGELOG.md + git commit --allow-empty -m "release: $TAG" + git tag -a "$TAG" -m "Ryotunes $TAG" -m "Source-Commit: $SOURCE_SHA" + git push origin "refs/tags/$TAG" + remote_head="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | cut -f1)" + if [[ "$remote_head" == "$SOURCE_SHA" ]]; then + git push origin "HEAD:refs/heads/$DEFAULT_BRANCH" \ + || echo "::notice::Branch moved or is protected; the release tag is complete, branch versions will sync on a later push." + fi + + package: + name: Build the Arch package + needs: release + if: needs.release.outputs.action != 'skip' + runs-on: ubuntu-24.04 + container: + image: archlinux:base-devel + steps: + # actions/checkout needs git present in the container before it can run. + - name: Install git for checkout + run: pacman -Syu --noconfirm --needed git + + - uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag }} + + # The shared script a maintainer runs locally too + # (docker run ... archlinux:latest scripts/build-arch-package.sh). It runs as + # root, sets up an unprivileged builder, re-execs itself, builds the reserved + # tag's exact source, and writes the epochless contract asset + # ryotunes--1-x86_64.pkg.tar.zst (+ .sha256) into dist/. + - name: Build the package + run: scripts/build-arch-package.sh "$GITHUB_WORKSPACE/dist" + + - name: Upload the package + uses: actions/upload-artifact@v4 + with: + name: arch-package + path: dist/ryotunes-*-x86_64.pkg.tar.zst* + if-no-files-found: error + + publish: + name: Publish the GitHub release + needs: [release, package] + if: needs.release.outputs.action != 'skip' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag }} + fetch-depth: 0 + + - name: Fetch the Arch package + uses: actions/download-artifact@v4 + with: + name: arch-package + path: dist + + - name: Source tarball + env: + TAG: ${{ needs.release.outputs.tag }} + VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + name="ryotunes-$VERSION" + # git archive is reproducible for a tag: same tree, same bytes. + git archive --format=tar --prefix="$name/" "$TAG" | gzip -n > "dist/$name.tar.gz" + ( cd dist && sha256sum "$name.tar.gz" > "$name.tar.gz.sha256" ) + + - name: Release notes + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release.outputs.tag }} + run: python3 scripts/release-notes.py "$TAG" > dist/NOTES.md + + - name: Create or update the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release.outputs.tag }} + run: | + set -euo pipefail + # A normal published (non-draft, non-prerelease, latest) release is what + # /repos/ryoku-dev/ryotunes/releases/latest returns to Ryoku's clients. + assets=(dist/*.tar.gz dist/*.pkg.tar.zst dist/*.sha256) + latest="$(gh release list --limit 1000 --json isLatest,tagName --jq '.[] | select(.isLatest) | .tagName')" + # A manual repair of an older tag must not downgrade releases/latest. + promote="$(python3 - "$TAG" "$latest" <<'PY' + import re, sys + def version(tag): + return tuple(map(int, tag[1:].split("."))) if re.fullmatch(r"v1\.(0|[1-9][0-9]*)\.[0-9]", tag) else (0, 0, 0) + print("true" if version(sys.argv[1]) >= version(sys.argv[2]) else "false") + PY + )" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "${assets[@]}" --clobber + gh release edit "$TAG" --draft=false --prerelease=false --latest="$promote" --notes-file dist/NOTES.md + else + gh release create "$TAG" "${assets[@]}" --latest="$promote" --title "Ryotunes $TAG" --notes-file dist/NOTES.md + fi diff --git a/.github/workflows/v23-build.yml b/.github/workflows/v23-build.yml deleted file mode 100644 index 83d9562..0000000 --- a/.github/workflows/v23-build.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Build v2.3 binaries - -on: - push: - branches: - - v2.3 - workflow_dispatch: - -jobs: - linux-x86_64: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libmpv-dev - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - uses: pnpm/action-setup@v4 - with: - version: 11 - run_install: false - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: ui/pnpm-lock.yaml - - - name: Frontend dependencies - working-directory: ui - run: pnpm install --frozen-lockfile - - - name: Frontend validation - working-directory: ui - run: pnpm check && pnpm build - - - name: Normalize Rust formatting in build workspace - run: cargo fmt --all - - - name: Static release guards - env: - RYOTUNES_STATIC_ONLY: "1" - run: ./scripts/release-check.sh - - - name: Verify frozen dependency graph - run: cargo fetch --locked - - - name: Rust tests and check - env: - CARGO_NET_OFFLINE: "true" - run: | - cargo test --workspace --locked --offline - cargo check --workspace --locked --offline - - - name: Install Tauri CLI - run: cargo install tauri-cli --locked --version "^2" - - - name: Build Ryotunes - env: - CARGO_NET_OFFLINE: "true" - run: cargo tauri build --no-bundle - - - name: Build sync helper - env: - CARGO_NET_OFFLINE: "true" - run: cargo build --release --locked --offline --package sync-server - - - name: Verify binaries - run: | - test -x target/release/ryotunes - test -x target/release/ryotunes-sync - file target/release/ryotunes - file target/release/ryotunes-sync - sha256sum target/release/ryotunes target/release/ryotunes-sync > v2.3-binary-SHA256SUMS.txt - - - uses: actions/upload-artifact@v4 - with: - name: ryotunes-v2.3-linux-x86_64-binaries - path: | - target/release/ryotunes - target/release/ryotunes-sync - v2.3-binary-SHA256SUMS.txt - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/v24-build.yml b/.github/workflows/v24-build.yml deleted file mode 100644 index b8ddf71..0000000 --- a/.github/workflows/v24-build.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Build v2.4 binaries - -on: - push: - branches: - - v2.4 - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - linux-x86_64: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev libmpv-dev - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - uses: pnpm/action-setup@v4 - with: - version: 11 - run_install: false - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: ui/pnpm-lock.yaml - - - name: Frontend dependencies - working-directory: ui - run: pnpm install --frozen-lockfile - - - name: Frontend validation - working-directory: ui - run: pnpm check && pnpm build - - - name: Normalize Rust formatting in build workspace - run: cargo fmt --all - - - name: Static release guards - env: - RYOTUNES_STATIC_ONLY: "1" - run: ./scripts/release-check.sh - - - name: Verify frozen dependency graph - run: cargo fetch --locked - - - name: Rust tests and check - env: - CARGO_NET_OFFLINE: "true" - run: | - cargo test --workspace --locked --offline - cargo check --workspace --locked --offline - - - name: Install Tauri CLI - run: cargo install tauri-cli --locked --version "^2" - - - name: Build Ryotunes - env: - CARGO_NET_OFFLINE: "true" - run: cargo tauri build --no-bundle - - - name: Build sync helper - env: - CARGO_NET_OFFLINE: "true" - run: cargo build --release --locked --offline --package sync-server - - - name: Verify binaries - run: | - test -x target/release/ryotunes - test -x target/release/ryotunes-sync - file target/release/ryotunes - file target/release/ryotunes-sync - sha256sum target/release/ryotunes target/release/ryotunes-sync > v2.4-binary-SHA256SUMS.txt - - - uses: actions/upload-artifact@v4 - with: - name: ryotunes-v2.4-linux-x86_64-binaries - path: | - target/release/ryotunes - target/release/ryotunes-sync - v2.4-binary-SHA256SUMS.txt - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/v24-package.yml b/.github/workflows/v24-package.yml deleted file mode 100644 index 4cebbad..0000000 --- a/.github/workflows/v24-package.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Package v2.4.1 release candidate - -on: - push: - branches: - - v2.4.1 - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - package-arch: - runs-on: ubuntu-24.04 - container: - image: archlinux:base-devel - - steps: - - name: Bootstrap checkout runtime - run: pacman -Syu --noconfirm --needed git nodejs ca-certificates - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Arch build dependencies - run: | - pacman -S --noconfirm --needed \ - rustup pnpm python libarchive zstd zip \ - webkit2gtk-4.1 libappindicator-gtk3 mpv openssl librsvg \ - desktop-file-utils hicolor-icon-theme xdg-utils \ - appmenu-gtk-module xdotool - - - name: Prepare unprivileged builder - run: | - id builder >/dev/null 2>&1 || useradd -m -s /bin/bash builder - chown -R builder:builder "$GITHUB_WORKSPACE" - echo "BUILDER_HOME=$(getent passwd builder | cut -d: -f6)" >> "$GITHUB_ENV" - - - name: Install Rust and Tauri CLI - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" bash -lc ' - rustup default stable - rustup component add rustfmt - cargo install tauri-cli --locked --version "^2" - ' - - - name: Install frontend dependencies - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - cd '$GITHUB_WORKSPACE/ui' - pnpm install --frozen-lockfile - " - - - name: Validate source - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - set -euo pipefail - cd '$GITHUB_WORKSPACE' - cargo fmt --all -- --check - RYOTUNES_STATIC_ONLY=1 ./scripts/release-check.sh - cd ui - pnpm check - pnpm build - cd .. - cargo fetch --locked - CARGO_NET_OFFLINE=true cargo test --workspace --locked --offline -- \ - --skip mpv_keeps_the_gain_through_pitch_changes_and_failures - CARGO_NET_OFFLINE=true cargo check --workspace --locked --offline - " - - - name: Build native release binaries - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" CARGO_NET_OFFLINE=true bash -lc " - set -euo pipefail - cd '$GITHUB_WORKSPACE' - cargo tauri build --no-bundle - cargo build --release --locked --offline --package sync-server - test -x target/release/ryotunes - test -x target/release/ryotunes-sync - " - - - name: Build release candidate assets - run: | - runuser -u builder -- env HOME="$BUILDER_HOME" PATH="$BUILDER_HOME/.cargo/bin:/usr/bin" bash -lc " - set -euo pipefail - cd '$GITHUB_WORKSPACE' - chmod +x scripts/build-replacement-package.sh scripts/make-source-release.sh \ - scripts/release-check.sh scripts/ryoku-window-rule.sh - rm -rf dist - mkdir -p dist - ./scripts/build-replacement-package.sh dist - RYOTUNES_STATIC_ONLY=1 ./scripts/make-source-release.sh \ - dist/ryotunes-v2.4.1-final-source.tar.gz - cd dist - sha256sum ryotunes-v2.4.1-final-source.tar.gz >> SHA256SUMS-v2.4.1.txt - sort -u SHA256SUMS-v2.4.1.txt -o SHA256SUMS-v2.4.1.txt - " - - - name: Inspect package ownership and checksums - run: | - cat dist/SHA256SUMS-v2.4.1.txt - bsdtar -tf dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst | grep -E 'usr/(bin|lib|share)' | head -80 - test "$(bsdtar -tf dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst | grep -c '^usr/bin/ryotunes-v2.4$')" -eq 1 - ! bsdtar -tf dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst | grep -Eq '^usr/bin/ryotunes$' - - - uses: actions/upload-artifact@v4 - with: - name: ryotunes-v2.4.1-release-candidate - path: | - dist/ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst - dist/Ryotunes-v2.4-Ryoku-x86_64.zip - dist/ryotunes-v2.4.1-linux-x86_64.tar.zst - dist/ryotunes-v2.4.1-final-source.tar.gz - dist/SHA256SUMS-v2.4.1.txt - if-no-files-found: error - retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index c26b151..47f8090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,111 @@ # Changelog +## Unreleased + +## v1.1.6 - 2026-09-25 + +- SoundCloud sign-in now happens in your own browser instead of a Ryotunes window: pressing Connect opens soundcloud.com (captchas and Google/Facebook/Apple popups work there), and the daemon watches your browser's cookie stores — Firefox-family and Chromium-family profiles, default browser first — importing the session the moment it appears and proving the token against `/me` before saving it. Already signed in in the browser? One click imports it instantly, no browser round trip. Cookie stores are only re-read when their file actually changes, and an unfinished sign-in ends with a clear message after five minutes rather than hanging. + +- Installing an update from Settings now restarts Ryotunes by itself: after the package lands, a detached helper waits for the old daemon to release its socket lock, then relaunches — socket-activating the new daemon and opening the new client — instead of leaving "quit and reopen" as the user's chore. If the helper fails, the app stays open with the old manual guidance as the fallback. + +## v1.1.5 - 2026-09-25 + +## v1.1.4 - 2026-09-24 + +- Fixed "Download playlist / Download album" always failing (albums: "missing field `video_id`", playlists: "missing field `videoId`"): two stacked bugs — the client mapped an album/playlist's rows into batch entries twice, dropping every track id, and the daemon's batch endpoint read its JSON in snake_case while the client posts camelCase. Single-track downloads were unaffected, which is why only albums and playlists broke. +- Album and playlist downloads now read as one collection: the queue and history group every batch under a single card showing the album/playlist cover, kind, aggregate progress and how many tracks are saved, waiting, failed or cancelled — expandable to its per-track rows. Batch tracks also land in a folder named after their collection on disk, and re-downloading still dedups against them wherever they sit. +- Added Settings ▸ Diagnostics: one rolling record of everything that went wrong, for both halves of the app. The daemon logs its own warnings and errors, every failed request, and panics to a ring buffer and a rotating file (`$XDG_DATA_HOME/dev.ryoku.ryotunes/logs/ryotunesd.log`); the client forwards the failures it sees (rejected actions, lost connections, error toasts), buffering them while the daemon is down and delivering them on reconnect. The page filters by level, source and text, copies the whole list to the clipboard, and opens the log folder — so "songs won't play" finally has evidence to show instead of a guess. +- Settings ▸ Storage's cache action is now a one-click playback repair: "Force clear caches" drops every layer that can wedge stream resolution — cached stream URLs and lyrics, mpv's on-disk audio bytes, the stored PoToken, the per-video WEB_REMIX failure blacklist — and rotates the anonymous YouTube playback identity (`visitorData`) on the spot, which is the manual fix for "Couldn't load this track — YouTube rejected the stream link". If the identity re-fetch fails (offline), the toast says so; the token is still dropped, so the next launch or a bot-gated resolve re-bootstraps it. Changing stream quality keeps clearing only the URL cache and no longer rotates the identity. + +## v1.1.1 - 2026-09-24 + +- Added SoundCloud sign-in: a Ryotunes window opens the site's own sign-in (account or Google/Facebook/Apple), captures the session's OAuth token from the webview's cookie jar, proves it against `/me` before saving it, and keeps it alive through SoundCloud's rotating refresh. Signing in is optional — SoundCloud still browses and plays as a guest — but a signed-in account adds its own playlists, liked tracks and followed artists to Home, and the token survives restarts (an expired one says so instead of silently vanishing). +- Home is now the merged feed: YouTube Music's shelves remain the spine (the default source, signed in or not), and every signed-in provider contributes its own shelves to the same page — Spotify's made-for-you rows when a Premium session exists, SoundCloud's personal rows when connected. Cards navigate and play across providers exactly as before; one provider being down never blanks the page. + +## v1.1.0 - 2026-09-24 + +- Fixed some users being unable to play any YouTube track ("Skipped (unavailable)" on everything): YouTube's anonymous playback clients now require a valid `visitorData`, and a failed first fetch used to leave the daemon without one for its whole life, so every stream request hit Google's bot gate. The startup bootstrap retries with backoff, and a resolve that is rejected by the bot gate now fetches a fresh `visitorData` on the spot and retries once. +- Fixed Spotify Premium users seeing "sign in" on every launch and every track skipped as "not available": the cached session is announced to clients once it restores (it used to finish silently after the UI snapshot, leaving the gate up), the sign-in gate explains a genuinely dead session instead of demanding a fresh login, a dead librespot session is detected and (once per credential lifetime) recovered from saved credentials before a track is reported as needing sign-in, a Spotify queue that needs sign-in now stops with the real reason instead of toast-storming "Skipped (unavailable)", and the Spotify device identity is persisted per installation instead of re-randomising on every launch. +- Added Download playlist to the playlist page menu: albums and playlists batch-download through one daemon call that gathers every continuation page and admits the whole collection against a single dedup pass. +- Downloaded tracks are now smart-deduplicated: re-downloading a collection (or a single track) skips audio already in the download folder even when it lives there under a different upload's id or a ` (N)` collision name, matching on the normalized "artist title" as well as the exact filename. + +## v1.0.8 - 2026-09-20 + +- Stopped Now Playing stuttering while the window is resized or scaled: the cover size read the column's assigned width and the body's assigned height, feeding layout outputs back into the children's preferred sizes, so the column re-polished itself on every pass. The cover now sizes from the root and the header's implicit height, which are layout inputs. + +- Fixed every page rendering blank under Ryoku's Reduce motion: the page-stack enter animation used render-thread Animators, which never apply their end value at duration 0, so the stack was stranded at opacity 0. The end state now lands directly when there is no animation to run. + +## v1.0.6 - 2026-09-08 + +- Fixed app-launcher activation after removing and reinstalling Ryotunes: removal now stops the user units before deleting them, and a fresh install repairs stale socket state left by older packages. Ordinary upgrades still leave playback and self-update RPCs running. +- Presented Ryotunes consistently as Ryoku's main music app in pacman, the app launcher, and software catalogues. + +## v1.0.5 - 2026-09-08 + +- Fixed Home snapping back to the top when a continuation page arrived: new shelves now append to a persistent Qt list model instead of replacing the model. +- Made the Linux launcher native-QML-only, including cold starts and missing sockets. Removed the legacy Tauri overrides and duplicate desktop entry; Quickshell is now a required package dependency. + +## v1.0.4 - 2026-09-08 +- Prevented automatic QML reload during package upgrades, which could reload `shell.qml` while pacman temporarily removed it. Quit and reopen after installation to load the new client and daemon together. +- Fixed the primary QML Home page jumping past its header on the first scroll. Asynchronous header growth stays pinned only until the user starts scrolling. +- Home now puts a compact resume control and remembered listening context ahead of discovery. Played songs, albums and playlists persist across launches without counting a restored queue as a new listen. +- Added searchable Home shortcuts from recents, the library and provider search, plus YouTube playlist-link previews. Duplicate/capacity feedback and a bounded scrolling list keep the controls usable in small windows. +- Added a bounded, on-device Home discovery ranker using the existing provider feed, listening recency, artist affinity and diversity. No additional recommendation service, model training or idle polling is introduced. + +## v1.0.3 - 2026-09-08 + +## v1.0.2 - 2026-09-08 + +## v1.0.1 - 2026-09-08 + +## v1.0.0 - 2026-09-08 + +- **Fresh v1 releases on every default-branch push.** The app, daemon, CLI, native client and package start at `1.0.0`. CI queues pushes, builds each exact source, and advances `1.0.9 → 1.1.0` (major fixed at 1). Releases include generated notes, binary/source archives and SHA-256 sidecars. A permanent pacman `epoch=1` makes v1 packages an upgrade from legacy v2 installations without changing the release asset filenames. Package hooks no longer restart the daemon during the transaction. +- **About credits and standalone updates.** Both clients credit ashmitvoid and neur0map and link LiMusic upstream. About shows current/available versions, checks GitHub on request, opens the changelog and installs verified official Arch packages with administrator approval, independently of a full Ryoku update. After installation, fully Quit and reopen Ryotunes to reload the client and daemon. +- **Releases ship a prebuilt Arch package.** Each release builds `ryotunes--1-x86_64.pkg.tar.zst` (and its `.sha256`) with an unprivileged `makepkg` in an Arch container from that release's exact source and attaches it to the GitHub release beside the reproducible source tarball, so `pacman -U` — and Ryoku's `update`/`doctor`, which read GitHub releases directly — no longer wait on a downstream rebuild. `scripts/build-arch-package.sh` reproduces the asset locally (`docker run --rm -v "$PWD:/src" -w /src archlinux:latest scripts/build-arch-package.sh`). The old `ryotunes-release` repository_dispatch into Ryoku Arch is removed. +- Release checks now validate synchronized manifest versions and shipped capability policy rather than pinning UI copy, comments, or implementation source text; version bumps no longer rewrite the check script. +- Added **Download album** to the native album menu: queues the complete album rather than filtered rows, follows continuation pages, reuses download preferences and bounded workers, and reports unavailable tracks or partial admission errors. +- **One-click music downloads in the native client.** A download control beside the heart saves the current track immediately; Downloads in the sidebar shows progress, waiting tracks and persistent history with cancel, retry and open-folder actions. Settings › Downloads adds the destination, Original/MP3/Opus formats, artist folders, embedded cover art & lyrics, and a safe 1–4 worker limit (default 1). Workers survive window closure, never overwrite existing files, and are terminated/reaped on quit; interrupted jobs remain retryable. YouTube and public SoundCloud use their source links; Spotify uses a clearly labelled YouTube search match. Downloads embed available cover art and plain lyrics where supported and save matching image and synced `.lrc` or plain `.txt` companion files. Local lyrics take precedence over network lookups for offline playback. Metadata lookups send track details to external providers; missing matches produce non-fatal history notes. +- **Device playlists get automatic artwork.** A local playlist's card and page now build a cover from its own songs: four distinct song covers become a 2x2 collage, one to three collapse to the first cover, an empty playlist shows none. A custom cover you pick still wins everywhere, and removing it (Reset) drops straight back to the automatic art without a round-trip to YouTube Music. Cards, the page header and the sidebar refresh the instant tracks are added or removed or a cover changes. +- Fixed native-client reconnection when the daemon was absent on the initial connection attempt, so download state and playback resynchronize without reopening the client. +- Join the mpv event reader before destroying its native handle, preventing a shutdown race exposed by download metadata enrichment. +- **Ryotunes v2 Discord presence.** The Rich Presence card is rebuilt around the v2 identity: the track's album art is the hero image with a small Ryotunes badge overlaid (**Ryotunes v2** on hover), and when a track has no shareable art (local files, missing thumbnails) the Ryotunes brand mark stands in rather than the underlying Discord application's icon. Hovering the art now shows the album and the service it is playing from, and the "listen" button is routed by provider — YouTube Music watch pages and Spotify `open.spotify.com` track pages, with SoundCloud (numeric-id only) and local/radio tracks left linkless rather than pointing somewhere wrong. The default "Listening to" title is now **Ryotunes v2**; a custom title you set is still kept. (The Discord application registration itself is still the upstream one — changing the name and icon Discord shows in its own app registry needs a Ryotunes-owned Application ID, which is pending.) + +## v2.5.1 - 2026-09-06 + +- **The heart saves without an account.** Liking a track when there is no YouTube Music session (or on a SoundCloud/local track) lands it in a device-local **Liked Songs** playlist that Library › Songs shows and the library lists; unliking removes it, the heart and the now-playing snapshot reflect it at once. Signed in, YouTube's own rating is used as before. + +## v2.5.0 - 2026-09-06 + +- Releases are one command: `scripts/release.sh patch|minor|major --push` bumps every manifest, rolls this changelog, tags; the `Release` workflow verifies the tagged tree builds (binaries, web UI, skins, tests), publishes the GitHub release with a reproducible source tarball + sha256, and tells Ryoku Arch, whose own workflow bumps the `[ryoku]` package and publishes it. The package enables `ryotunesd.socket` for every user (preset + install hook), which is what made a fresh install open the old Tauri app before. + +- Power: the cover wash no longer drifts every frame (the client sat at ~35 % of a core while playing; now ~1.6 %). Pause-to-quit: a minute into a pause with the main window off screen the client exits, and the daemon's idle grace is now 60 s (was 5 min), so a paused, closed Ryotunes is fully out of memory about two minutes after the pause; socket activation brings it back on the next launch, media key or MPRIS call. Signing out drops the account's recents (Liked Music, library playlists) from Home's Jump back in. + +- Seven expressive shipped skins beside Paper/Ember/Mist - **Neon**, **Vapor**, **Phosphor**, **Arcade**, **Broadsheet**, **Concrete**, **Velvet** - each with its own bundled OFL faces (Unbounded, Playfair, Major Mono, Press Start, Bebas Neue, Cormorant…), radii, motion and wash. Every display title now takes the skin's face (three surfaces still read Ryoku's). `RYOTUNES_SKIN_MODE` pins a mode for previews so light-first skins render light. + +- Skins are RyoStore products: the store's new `ryotunes-skins` category installs into `~/.local/share/ryoku/ryotunes-skins//`, which Ryotunes reads as the STORE source (after user skins, before shipped) and watches, so an install shows up in the picker without a reload; *Settings › Appearance › Get more skins* opens the store on the category; `ryotunes-cli skin use ` selects a skin from a shell. The catalogue ships 20 skins derived from Ryoku's colour schemes. + +- **Skins.** One source for every colour, face, radius and duration: `system` (the Ryoku desktop, live), `matugen` (a skin written by a matugen template from the wallpaper; on Ryoku one of the Theme apps), or any `skin.json` folder under `~/.config/ryotunes/skins/` or `/usr/share/ryotunes/skins/` (shipped: paper, ember, mist). Live reload while editing, a picker + fork + folder buttons in Settings › Appearance, `ryotunes-cli skin check|list|show`, a JSON schema, `docs/SKINS.md` and `skins/README.md` for contributors, `scripts/dev/skin-preview.sh` for the 800x500 previews. Decor gains a "Skin default" level. + +- **SoundCloud** is the third provider, no account needed (`crates/soundcloud`, api-v2 with a scraped `client_id`, HLS AAC 160k straight into mpv). Search, artists, albums/playlists, Discover Home (Trending by genre, Curated, Artists to watch), related-track autoplay, and SoundCloud's own per-track **waveform** as the seek bar in the player bar, the Home card and the Now Playing stage. SoundCloud artist pages take the Orange/SoundCloud shape: banner, avatar, followers, tracks left, "Albums from this user" right. +- Now Playing stage re-laid: header row with the Collapse pill, top-aligned art + title column, one foot band (waveform or spectrum); the glow box is gone. +- Spotify stays Premium-only (librespot); the sign-in gate now says why a sign-in failed. + +- Client visual pass to the old design's proportions (`docs/superpowers/specs/2026-09-05-eye-candy-spacing-pass.md`): Home hero with the Now Playing card, faded chip row, `// Section` headings, Shortcuts / Jump back in / Familiar artists panel; sidebar brand card, numbered groups, playlists with `+ New playlist`; queue rows that keep their titles readable; Now Playing stage with a `Collapse` pill. The stage was stuck open: the player bar's expand button now toggles it and Escape closes it. + +- Added the native Ryotunes client: `ryotunesd`, a Tauri-free daemon (playback, Innertube, library, MPRIS, tray, Last.fm, Discord, Listen Together, socket-activated via `ryotunesd.socket`, exits five minutes after the last subscriber leaves with nothing playing), `ryotunes-cli`, and a pure-QML client run by Quickshell (`ryotunes-qml`, installed at `/usr/share/ryotunes/client`) that reproduces the Svelte UI (Home, Search, Library, Playlist, Album, Artist, List, Radio, Settings, Queue, Lyrics, Now Playing, Listen Together, mini player, command palette) with the Ryoku theme singletons. The native client is the default: `ryotunes` asks the daemon to show (socket activation starts it after a boot), which raises the connected client or opens `ryotunes-qml`; the Tauri app runs only on `ryotunes --tauri` or where no daemon socket exists. Measured on a Ryzen 7940HS laptop: paused idle 0.1% CPU (Tauri: 2% plus three WebKit processes), playing idle 0.7% + 2.1%, Home scroll 0.8% (Tauri: 60% WebKit + 16% host). +- Fixed reopening the native client after Super+Q: the daemon's `show` event was not handled by the client at all, and Quickshell leaves a compositor-closed `FloatingWindow` at `visible: true`, so the window never came back until the process was killed. The client now handles `show` and remaps the window. +- Rebuilt the native client on one visual system (`docs/superpowers/specs/2026-09-05-native-client-visual-design.md`): Sonora's three-column frame (title bar, 248 px rail, content, a persistent 340 px Queue / Lyrics panel, an 88 px player bar with the transport centred on the window) in Ryoku's flat-paper, hairline, bone-plate language; one type scale (Fraunces titles, Space Grotesk body, tracked mono labels); page heroes and table-style track lists; the playing cover as the room's light (a static blurred backdrop that cross-fades on track change and drifts only while playing) and its sampled accent on playback state only; the Now Playing stage with a spectrum ribbon fed by cava while visible and playing; snapshot-blurred modals. Passive motion is gated on playing, not reduce-motion and not power-saver. +- Restored the compact player as the Tauri widget's 724 x 356 geometry: a layer-shell surface with the widget as a draggable item inside it (the previous surface-dragging approach fed its own deltas back), above windows, position remembered. +- Added Sound: tempo, pitch, reverb, bass and stereo width on the daemon's mpv filter chain (`set_audio_fx` / `get_audio_fx`, `audio-fx` event) with presets (Slowed + Reverb, Nightcore, Bass Boost, Wide) from the player bar and Settings. +- Added `crates/spotify`, a Spotify provider lifted from nolight132/sonora (GPL-3.0-or-later): OAuth sign-in, Pathfinder / protobuf metadata, collection, playlists, colour-lyrics, and streaming through librespot's pipe sink into a FIFO mpv plays raw, so every mpv feature (the Sound chain, MPRIS) applies to Spotify audio. The title bar's provider switch lights the window red for YouTube Music and green for Spotify (a tweened glow, the page re-entering on a switch); daemon routing behind it is the next step, and YouTube Music is unchanged. +- Extracted `crates/core` (innertube clients, player, state, library, integrations) out of `src-tauri`; the Tauri host is now command forwarders over `ryotunes_core`. +- Fixed the Arch package build under makepkg's `lto` option (`options=(!lto)`): the vendored sqlite and ring C objects cannot be linked by lld when compiled with `-flto`. +- Fixed the timeline freezing for the rest of a session after a seek-thumb drag released outside the window: WebKitGTK delivered neither `change` nor `pointerup`, so the locally held drag value shadowed every later position tick (reproduced by dragging past the window's bottom edge; MPRIS kept ticking, the bar did not). Both seek sliders now commit the pending drag on pointer release, capture loss, window blur, the first buttonless pointer move, or a track change. +- Removed the scroll-time `ryo-is-scrolling` class toggle: rules keyed on it reached every image and every promoted artwork layer, so each wheel notch cost a whole-page style recalculation plus compositing-layer churn, twice. Scrolling stays paint-bound in WebKitGTK (about 60% of a core while scrolling on a 2560x1600 panel, unchanged), which the native client work addresses. +- Added `RYOTUNES_WEBKIT_FEATURES=Name=1,...` to flip WebKitGTK feature flags (for example `CompositingBordersVisible`, `CompositingRepaintCountersVisible`) when profiling the renderer. +- Brought the release gate back to green on a clean checkout: the reveal-failsafe invariant matched the old 1500 ms, the private-path scan tripped on a git worktree's `.git` file, and two files had drifted from rustfmt. + ## v2.4.1 — 2026-09-02 - Isolated the remote Google login and hidden cipher/PoToken JavaScript WebViews from all Ryotunes application commands using Tauri's runtime-authority AppManifest and an explicit main/mini-only command permission. diff --git a/Cargo.lock b/Cargo.lock index 71f6de7..959e27d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -44,6 +55,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -59,6 +76,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -328,6 +351,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -373,6 +402,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -519,6 +557,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.4" @@ -586,11 +633,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -658,6 +717,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.2" @@ -833,14 +898,47 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -856,13 +954,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -921,6 +1030,17 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -941,6 +1061,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -969,7 +1120,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1146,6 +1299,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "embed-resource" version = "3.0.11" @@ -1166,6 +1325,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1400,6 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1476,6 +1645,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -1736,6 +1911,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "futures-sink", + "futures-timer", + "futures-util", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1788,6 +1982,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1803,6 +2016,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1818,6 +2042,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -1848,6 +2096,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1903,6 +2169,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.0" @@ -1913,6 +2185,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1923,6 +2196,46 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-proxy2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls 0.26.0", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1932,9 +2245,10 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.43", + "rustls-native-certs 0.8.4", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.9", ] @@ -1957,9 +2271,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.5", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2140,7 +2456,7 @@ dependencies = [ [[package]] name = "innertube" -version = "2.4.1" +version = "1.1.6" dependencies = [ "hex", "reqwest 0.12.28", @@ -2154,6 +2470,16 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2180,6 +2506,25 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2368,6 +2713,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -2418,6 +2766,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libmpv2" version = "4.1.0" @@ -2443,20 +2797,155 @@ dependencies = [ ] [[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +name = "librespot-audio" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" + "aes", + "bytes", + "ctr", + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "librespot-core", + "log", + "tempfile", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "librespot-core" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" +dependencies = [ + "aes", + "base64 0.22.1", + "byteorder", + "bytes", + "data-encoding", + "flate2", + "form_urlencoded", + "futures-core", + "futures-util", + "governor", + "hmac", + "http", + "http-body-util", + "httparse", + "hyper", + "hyper-proxy2", + "hyper-rustls 0.27.9", + "hyper-util", + "librespot-oauth", + "librespot-protocol", + "log", + "nonzero_ext", + "num-bigint", + "num-derive", + "num-integer", + "num-traits", + "pbkdf2", + "pin-project-lite", + "priority-queue", + "protobuf", + "protobuf-json-mapping", + "quick-xml 0.38.4", + "rand 0.9.5", + "rand_distr", + "rsa", + "serde", + "serde_json", + "sha1", + "shannon", + "sysinfo", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.28.0", + "tokio-util", + "url", + "uuid 1.25.0", + "vergen-gitcl", +] + +[[package]] +name = "librespot-metadata" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" +dependencies = [ + "async-trait", + "bytes", + "librespot-core", + "librespot-protocol", + "log", + "protobuf", + "serde", + "serde_json", + "thiserror 2.0.20", + "uuid 1.25.0", +] + +[[package]] +name = "librespot-oauth" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" +dependencies = [ + "log", + "oauth2", + "open", + "reqwest 0.12.28", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "librespot-playback" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" +dependencies = [ + "form_urlencoded", + "futures-util", + "librespot-audio", + "librespot-core", + "librespot-metadata", + "log", + "portable-atomic", + "rand 0.9.5", + "rand_distr", + "shell-words", + "symphonia", + "thiserror 2.0.20", + "tokio", + "zerocopy", +] + +[[package]] +name = "librespot-protocol" +version = "0.8.0" +source = "git+https://github.com/nolight132/librespot?rev=e7eb953d5848fd97bacd00e6c0e33500765eaa63#e7eb953d5848fd97bacd00e6c0e33500765eaa63" +dependencies = [ + "protobuf", + "protobuf-codegen", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] @@ -2473,7 +2962,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "listen-protocol" -version = "2.4.1" +version = "1.1.6" dependencies = [ "serde", "serde_json", @@ -2695,6 +3184,21 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2704,12 +3208,68 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2717,6 +3277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2750,6 +3311,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.7", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -2887,6 +3468,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -2970,6 +3561,28 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3052,6 +3665,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3146,6 +3778,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3154,10 +3807,12 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "player" -version = "2.4.1" +version = "1.1.6" dependencies = [ "libc", "libmpv2", + "parking_lot", + "serde", "thiserror 2.0.20", "tokio", "tracing", @@ -3171,7 +3826,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -3283,6 +3938,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.14.0", + "serde", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3345,6 +4011,78 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-json-mapping" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" +dependencies = [ + "protobuf", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -3366,7 +4104,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.43", "socket2 0.6.5", "thiserror 2.0.20", "tokio", @@ -3387,7 +4125,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "slab", "thiserror 2.0.20", @@ -3507,6 +4245,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.5", +] + [[package]] name = "rand_pcg" version = "0.10.2" @@ -3610,27 +4358,29 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.43", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -3752,6 +4502,26 @@ dependencies = [ "cc", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3821,6 +4591,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.43" @@ -3830,11 +4614,45 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3845,6 +4663,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3893,40 +4722,161 @@ dependencies = [ [[package]] name = "ryotunes" -version = "2.4.1" +version = "1.1.6" dependencies = [ "anyhow", + "async-trait", + "innertube", + "ksni", + "libc", + "player", + "rand 0.8.7", + "ryotunes-core", + "ryotunes-protocol", + "serde", + "serde_json", + "souvlaki", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", + "tauri-plugin-single-instance", + "thiserror 2.0.20", + "tokio", + "tracing", + "tracing-subscriber", + "urlencoding", + "webkit2gtk", + "windows 0.61.3", +] + +[[package]] +name = "ryotunes-cli" +version = "1.1.6" +dependencies = [ + "ryotunes-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "ryotunes-core" +version = "1.1.6" +dependencies = [ + "anyhow", + "async-trait", "base64 0.22.1", "discord-rich-presence", "futures-util", "innertube", - "ksni", "libc", "listen-protocol", "lofty", "md-5", + "parking_lot", "player", "rand 0.8.7", "regex", "reqwest 0.12.28", "rusqlite", - "rustls", + "rustls 0.23.43", + "ryotunes-soundcloud", + "ryotunes-spotify", "serde", "serde_json", + "sha2", "souvlaki", - "tauri", - "tauri-build", - "tauri-plugin-autostart", - "tauri-plugin-dialog", - "tauri-plugin-single-instance", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "ryotunes-protocol" +version = "1.1.6" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "ryotunes-soundcloud" +version = "1.1.6" +dependencies = [ + "base64 0.22.1", + "parking_lot", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "ryotunes-spotify" +version = "1.1.6" +dependencies = [ + "anyhow", + "bytes", + "http", + "libc", + "librespot-core", + "librespot-oauth", + "librespot-playback", + "librespot-protocol", + "log", + "oauth2", + "protobuf", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "url", + "uuid 1.25.0", + "vergen", +] + +[[package]] +name = "ryotunesd" +version = "1.1.6" +dependencies = [ + "aes", + "anyhow", + "async-trait", + "base64 0.22.1", + "cbc", + "glib", + "gtk", + "innertube", + "javascriptcore-rs", + "ksni", + "libc", + "lofty", + "parking_lot", + "pbkdf2", + "player", + "rusqlite", + "ryotunes-core", + "ryotunes-protocol", + "ryotunes-soundcloud", + "serde", + "serde_json", + "sha1", + "sha2", + "tempfile", "thiserror 2.0.20", "tokio", - "tokio-tungstenite", "tracing", "tracing-subscriber", + "url", "urlencoding", "webkit2gtk", - "windows 0.61.3", ] [[package]] @@ -3944,6 +4894,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4001,6 +4960,42 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -4096,6 +5091,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -4173,7 +5179,7 @@ version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -4232,6 +5238,15 @@ dependencies = [ "digest", ] +[[package]] +name = "shannon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561" +dependencies = [ + "byteorder", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4241,6 +5256,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -4257,6 +5278,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4368,6 +5399,31 @@ dependencies = [ "zvariant 3.15.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4427,6 +5483,103 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -4462,14 +5615,14 @@ dependencies = [ [[package]] name = "sync-server" -version = "2.4.1" +version = "1.1.6" dependencies = [ "futures-util", "listen-protocol", "rand 0.8.7", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", "tracing-subscriber", ] @@ -4494,6 +5647,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5023,13 +6211,35 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.43", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] @@ -5041,14 +6251,30 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "tokio", - "tokio-rustls", - "tungstenite", + "tokio-rustls 0.26.4", + "tungstenite 0.24.0", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.43", + "rustls-native-certs 0.8.4", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5058,6 +6284,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5335,13 +6562,32 @@ dependencies = [ "httparse", "log", "rand 0.8.7", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls 0.23.43", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5506,6 +6752,44 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -5735,6 +7019,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5789,6 +7083,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5967,6 +7273,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 1e20f83..ec09596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,17 @@ members = [ "crates/player", "crates/listen-protocol", "crates/sync-server", + "crates/core", + "crates/spotify", + "crates/soundcloud", + "crates/protocol", + "crates/ryotunesd", + "crates/ryotunes-cli", "src-tauri", ] [workspace.package] -version = "2.4.1" +version = "1.1.6" edition = "2021" license = "GPL-3.0-or-later" @@ -23,6 +29,9 @@ thiserror = "2" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } rand = "0.8" listen-protocol = { path = "crates/listen-protocol" } +async-trait = "0.1" +ryotunes-core = { path = "crates/core" } +ryotunes-protocol = { path = "crates/protocol" } # Release profile: thin LTO + one codegen unit for cross-crate optimization, symbols stripped # for size: the desktop package should stay lean without changing runtime behaviour. @@ -32,3 +41,14 @@ listen-protocol = { path = "crates/listen-protocol" } lto = "thin" codegen-units = 1 strip = "symbols" + +# Spotify streaming (crates/spotify) needs librespot 0.8 with nolight132's librespot-audio fix; the +# whole set is pinned to the fork rev Sonora uses so the librespot crates stay mutually consistent. +# No other workspace crate depends on librespot, so this only affects crates/spotify. +[patch.crates-io] +librespot-audio = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } +librespot-core = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } +librespot-metadata = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } +librespot-oauth = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } +librespot-playback = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } +librespot-protocol = { git = "https://github.com/nolight132/librespot", rev = "e7eb953d5848fd97bacd00e6c0e33500765eaa63" } diff --git a/README.md b/README.md index f317a4a..1ccf10f 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,21 @@
-Latest release -GPL-3.0-or-later +Latest release +GPL-3.0-or-later Linux x86_64 Ryoku native Audio only
Rust -Tauri 2 -Svelte 5 +Quickshell / QML libmpv

**A Ryoku-native desktop music player with native audio playback, live shell theming, and a UI that knows when to disappear.** -[Download](https://github.com/ashmitvoid/ryotunes/releases/latest) · [Architecture](docs/ARCHITECTURE.md) · [Install](docs/INSTALL-ARCH.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Ryoku](https://github.com/neur0map/ryoku-arch) +[Download](https://github.com/ryoku-dev/ryotunes/releases/latest) · [Architecture](docs/ARCHITECTURE.md) · [Install](docs/INSTALL-ARCH.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Ryoku](https://github.com/ryoku-dev/ryoku-arch) @@ -28,7 +27,59 @@ 2026_08_27_02_44_21_screenshot -

Repository artwork based on the live v2.4 Home layout and Ryoku visual language.

+

Repository artwork based on the live v2 Home layout and Ryoku visual language.

+ +### The primary client: Quickshell / QML + +**The QML client is the primary Ryotunes application.** It includes the YouTube, +Spotify and SoundCloud provider selector, downloads, and Settings → About +software-update controls. The Tauri/WebKitGTK interface is the legacy client, +not its replacement. Package version numbers alone do not identify the client: +the release package currently contains both. + +To update an existing installation, download the Arch package and matching +checksum from [the latest release](https://github.com/ryoku-dev/ryotunes/releases/latest). +Verify it before changing the installed application: + +```bash +sha256sum -c ryotunes-1.0.1-1-x86_64.pkg.tar.zst.sha256 +``` + +Use the filenames for your downloaded release and continue only on `OK`. +Quit Ryotunes completely, then replace the package in one transaction; there is +no need to uninstall the primary QML application first: + +```bash +systemctl --user stop ryotunesd.service ryotunesd.socket +sudo pacman -U ./ryotunes-1.0.1-1-x86_64.pkg.tar.zst +systemctl --user daemon-reload +systemctl --user enable --now ryotunesd.socket +ryotunes +``` + +The package's `1:` pacman epoch orders `1:1.0.1-1` above distribution-numbered +builds such as `2.5.1-1`; it does not mean QML is obsolete. Personal configuration +and music are preserved. If pacman reports file conflicts, check ownership with +`pacman -Qo /path/to/file` and back up unowned files before retrying. Do not use +blanket `--overwrite` or bypass dependency checks with `-Rdd`. + +**If the legacy interface opens:** quit it, then restart socket activation: + +```bash +systemctl --user restart ryotunesd.socket +systemctl --user start ryotunesd.service +ryotunes-cli show +``` + +The launcher can fall back to Tauri when the daemon socket is missing or refuses +connections. Verify **Settings → About → Client: QUICKSHELL / QML**, the provider +selector and Downloads sidebar rather than judging the client by its version. +Check for updates from that QML About page. + +To remove Ryotunes entirely instead of updating it, quit the app and run +`sudo pacman -R ryotunes`. This removes both packaged clients, not just Tauri; +do not use it to remove the legacy interface while keeping QML. + --- @@ -36,7 +87,7 @@ Ryotunes is a Linux-first desktop music application shaped around the way **Ryoku + Hyprland** actually behave. -The visible interface is Svelte/WebKitGTK. Playback is not. Rust and libmpv own the audio session, desktop integration, lifecycle, and media state. That separation lets Ryotunes stay visually rich while open and become dramatically quieter when the main interface is no longer needed. +The primary interface is Quickshell / QML, connected to the Rust `ryotunesd` daemon. Rust and libmpv own playback, media state and provider integrations independently of the visible client. The older Svelte/WebKitGTK interface remains a separate legacy launch mode. @@ -55,7 +106,7 @@ Audio stays in **libmpv**, outside the frontend renderer. MPRIS, media keys, tra @@ -70,17 +121,39 @@ During background playback, the expensive visible WebKit surface can be **destro | Surface | What Ryotunes does | |---|---| -| **Home** | Stable, non-virtualized sections, listening console, recommendations and progressive loading without scroll-jitter regressions | +| **Home** | Resume listening, remembered songs/albums/playlists, searchable shortcuts and local discovery ranking without the first-scroll jump | | **Search** | Songs, albums, artists and playlists with bounded incremental loading and preserved navigation state | | **Library** | Liked music, account playlists, persistent device playlists and local music in the same desktop flow | | **Radio** | Demand-driven Internet Radio directory with native libmpv live-stream playback | | **Now Playing** | Artwork-first playback surface with queue, metadata and lyrics access | | **Lyrics** | Synced lyrics with click-to-seek and mini-player follow mode | | **Queue** | Manual queue control plus radio / continuation behaviour | +| **Downloads (native client)** | One-click audio downloads beside the heart, a bounded background queue, persistent history and configurable file preferences | | **Mini-player** | A separate compact Ryoku surface with its own exact Hyprland title and independent geometry | | **Integrations** | MPRIS, hardware media keys, tray, Last.fm, configurable Discord Rich Presence and optional Listen Together | +### Home in the primary QML client + +- **Continue listening** resumes the queue restored by the daemon; reopening does not start playback automatically. +- **Recently played** remembers songs, albums and playlists across launches. Items already saved as shortcuts are not repeated in that row. +- **Add shortcut** lets you filter recents and your library, search the selected provider, or paste a YouTube / YouTube Music playlist link. A link is previewed with its real title and artwork before you add it. Private or unavailable playlists show an error; duplicate shortcuts are not added. +- **Picked for you** re-ranks the selected provider's existing Home feed using local listening history, recency and artist variety. With no relevant history, it uses the provider's discovery ordering instead of claiming learned preferences. This adds no recommendation API, paid service or background training: at most 300 candidates are considered for 12 picks, only when the feed or personal data changes. Displaying additional artwork still has a memory cost. + --- +## Download music in the native client + +Click the **download arrow beside the heart** in the player to start saving the current track immediately. The icon shows progress, then a check mark when the file is saved. Clicking an active or completed download opens **Downloads** in the sidebar; failed and cancelled downloads can be retried. + +To save an entire album, open its **⋯ menu → Download album**. This queues all downloadable tracks in album order, including any additional pages, regardless of the track filter. It uses the same saved preferences and worker limit as single-track downloads. The confirmation reports how many tracks are in Downloads and any unavailable tracks; a queue or connection error reports partial progress rather than claiming the whole album was added. Spotify albums still use labelled YouTube matches, not Spotify audio exports. + +**Settings › Downloads** controls the destination folder, Original / MP3 / Opus format, artist subfolders, embedded cover art & lyrics, and simultaneous workers. The default destination is your XDG Music folder's `Ryotunes/` directory (usually `~/Music/Ryotunes`), with original audio and **one worker**. You can choose up to four workers; additional tracks wait in a queue capped at 200. Save preferences explicitly. File preferences are captured when a track is queued; changing the worker limit changes admission immediately without interrupting running jobs. + +The Downloads page separates the live queue from the latest 200 history records, with cancel, retry and open-folder actions. Closing the window leaves downloads running. Quitting Ryotunes terminates and reaps its downloader workers; interrupted jobs remain in history for an explicit retry after restart. Clearing history never deletes music, and existing files are never overwritten. + +With **Embed cover art & lyrics** on (the default), downloads embed available artwork and plain lyrics where the audio format supports them, and save matching `.jpg`/`.png` artwork plus synced `.lrc` or plain `.txt` lyrics alongside the song. Ryotunes reads local `.lrc`, embedded lyrics, then `.txt` before consulting its network cache/providers, so saved lyrics work offline. Artwork uses the track thumbnail with a name-and-artist iTunes lookup fallback; lyrics reuse the existing providers, including LRCLIB. These services receive track metadata such as title, artist, duration and provider identifiers. Missing matches produce a non-fatal history note rather than failing the audio download. Existing songs are not backfilled or overwritten; download them again to add metadata. + +Downloads require **yt-dlp and FFmpeg** (included in the native source package's dependencies). YouTube and public SoundCloud tracks use their source links. **Spotify uses a labelled YouTube search match**, not an export of the Spotify stream, and the matched recording may differ. Local files, live radio, DRM-protected and unavailable tracks are not downloadable. Only download music you have permission to save. + ## Ryoku integration @@ -93,9 +166,21 @@ Ryotunes does not implement a disconnected theme layer and then approximate Ryok - **MPRIS continuity** — Ryoku media controls remain available when the visible main UI is closed. - **Rollback-safe replacement packaging** — custom Ryotunes replaces only the stock Ryotunes entry points, never `ryoku-desktop`. +### Skins + +Every colour, type face, radius and duration the chrome reads comes from one **skin**. The default, `system`, follows the Ryoku desktop live (wallpaper palette, named scheme, motion scale, decor). The `matugen` skin is the same manifest written by a matugen template from your wallpaper — on Ryoku it is one of the *Theme apps*, regenerated on every palette change. Any folder with a `skin.json` is a skin, and editing it re-paints the running app: + +``` +/usr/share/ryotunes/skins// shipped: paper, ember, mist +~/.config/ryotunes/skins// yours (shadows a shipped id); skins/matugen is the generated one +/usr/share/ryotunes/matugen/ryotunes.json the matugen template +``` + +Pick one in *Settings › Appearance*, or **Get more skins** there — that opens [RyoStore](https://github.com/ryoku-dev/ryostore) on its *Ryotunes skins* category, and an install lands in `~/.local/share/ryoku/ryotunes-skins//`. To make your own: fork the painted palette with **New skin from current**, validate with `ryotunes-cli skin check `, and submit it to the store catalogue as `ryotunes-skins//` — the format and the checklist are in **[docs/SKINS.md](docs/SKINS.md)**. + For the shell itself: -**[Ryoku Arch](https://github.com/neur0map/ryoku-arch)** · **[Ryoku Discord](https://discord.gg/8KjBmUEyKA)** +**[Ryoku Arch](https://github.com/ryoku-dev/ryoku-arch)** · **[Ryoku Discord](https://discord.gg/8KjBmUEyKA)** --- @@ -103,10 +188,10 @@ For the shell itself: | Situation | Behaviour | |---|---| -| Main window open | Full Svelte/WebKit UI is active | -| Close while music is playing | Main WebKit can hibernate; native playback + MPRIS + tray remain alive | -| Tray-only, nothing playing | Application exits after the bounded 5-minute idle period | -| Reopen | Main WebView is reconstructed and resynchronized from native state | +| Main window open | The primary Quickshell / QML client is connected to the daemon | +| Close while music or downloads are active | The daemon continues the active work | +| Idle with no client | The daemon exits after its idle grace period | +| Reopen | The QML client reconnects to daemon-owned state | | Explicit **Quit** | Playback stops, MPRIS unregisters, media state clears and backend integrations shut down | This is why the UI is a **client of playback state**, not the transport clock that owns it. @@ -120,20 +205,19 @@ flowchart LR R["Ryoku / Hyprland"] --> M["MPRIS · media keys · tray"] R --> T["live palette + window policy"] - M --> N["Tauri / Rust host"] + M --> N["ryotunesd / Rust daemon"] T --> N N --> P["native player crate"] P --> MPV["libmpv · audio only"] - N --> I["Innertube · library · integrations"] - N --> W["Svelte 5 / WebKitGTK"] + N --> I["YouTube · Spotify · SoundCloud · downloads"] + N <-->|"Unix socket RPC + events"| W["Quickshell / QML"] W --> U["Home · Search · Library"] W --> Q["Queue · Lyrics · Now Playing"] W --> MINI["Ryotunes Mini"] - W -. "hibernate while background playback continues" .-> N ``` More detail: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** @@ -145,9 +229,7 @@ More detail: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** Ryotunes treats background efficiency as an architectural requirement, not a cleanup pass. - event-driven playback state instead of a permanent 100 ms global frontend transport timer; -- no always-on FFT / requestAnimationFrame loop for ordinary idle playback; -- main WebKit hibernation during background playback; -- stable Home DOM — physical Home virtualization is deliberately avoided; +- daemon-owned playback and downloads independent of the visible QML window; - bounded artwork decode/cache paths; - native playback state remains authoritative across close, tray, mini-player and reopen transitions; - Internet Radio discovery is demand-driven — no startup fetch or permanent station polling loop. @@ -158,28 +240,22 @@ The design rules behind the interface are documented in **[docs/DESIGN.md](docs/ ## Install -Ryotunes v2.4 targets **x86_64 Ryoku, CachyOS and Arch-based systems**. +Ryotunes targets **x86_64 Ryoku, CachyOS and Arch-based systems**. -Package identity: **`ryotunes-v2.4 2.4.1-1`**. +The published package is **`ryotunes 1:-1`**. Its permanent `epoch=1` orders it above distribution-numbered 2.x packages; filenames stay epochless. This numbering transition is not a switch from QML to Tauri. -The normal user path is a **prebuilt package**. End users do not need Node, pnpm, Rust, Cargo or a local Tauri build. +The normal user path is a **prebuilt package**. End users do not need Node, pnpm, Rust or Cargo. -1. Open **[GitHub Releases](https://github.com/ashmitvoid/ryotunes/releases/latest)**. -2. Download `ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst`. -3. Install: +1. Open **[GitHub Releases](https://github.com/ryoku-dev/ryotunes/releases/latest)**. +2. Download `ryotunes--1-x86_64.pkg.tar.zst` and its `.sha256`. +3. Verify and install: ```bash -sudo pacman -U ./ryotunes-v2.4-2.4.1-1-x86_64.pkg.tar.zst -``` - -The active route is: - -```text -/usr/bin/ryotunes - -> /usr/lib/ryotunes-v2.4/ryotunes +sha256sum -c ryotunes--1-x86_64.pkg.tar.zst.sha256 +sudo pacman -U ./ryotunes--1-x86_64.pkg.tar.zst ``` -The replacement package preserves genuine stock Ryotunes entry points for rollback and restores them when the custom package is removed. +The package installs `/usr/bin/ryotunes`, `ryotunesd`, `ryotunes-cli` and the primary QML client under `/usr/share/ryotunes`. Follow the update and socket-activation instructions above when replacing an existing installation. > [!TIP] > An AUR `-bin` recipe lives in [`aur/`](aur/). Public AUR publication is pending; the repository does not pretend the package is available there before it actually is. @@ -198,8 +274,10 @@ crates/ listen-protocol/ shared Listen Together protocol sync-server/ optional room relay -src-tauri/ Tauri host, lifecycle, MPRIS, tray, integrations -ui/ SvelteKit / Svelte 5 interface +client/ primary Quickshell / QML interface +crates/ryotunesd/ playback daemon, provider RPC and downloads +src-tauri/ legacy Tauri host +ui/ legacy Svelte / WebKitGTK interface integrations/ Ryoku / shell integration assets packaging/ Arch / Ryoku replacement packaging scripts/ diagnostics, release gates and packaging tools @@ -273,6 +351,6 @@ Distributed under **[GPL-3.0-or-later](LICENSE)**. ### Music, shaped for the Ryoku desktop. -Rust · Tauri 2 · Svelte 5 · WebKitGTK · libmpv · Hyprland +Rust · Quickshell / QML · libmpv · Hyprland diff --git a/client/App.qml b/client/App.qml new file mode 100644 index 0000000..02db6cd --- /dev/null +++ b/client/App.qml @@ -0,0 +1,517 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Ryoku.Ui.Singletons +import Ryoku.Ui as RU +import "chrome" +import "components" + +// The app frame (spec section 3): the title register on top, the collapsible navigation rail and the +// routed page over the cover bloom, the persistent Queue/Lyrics panel on the right, and the transport +// pinned to the foot. Above the frame sits a foreground layer for the account menu, the toasts, the +// Ctrl+K palette, the Sound dialog and the Listen Together sheet. The page is chosen from +// Router.current.page and loaded by URL; any unknown route lands on a placeholder until its task adds +// it. The single Backdrop behind the content is the only saturated thing on screen; every chrome +// surface is Tokens paper (opaque or at alpha) so nothing else competes with the artwork. +Item { + id: app + anchors.fill: parent + + // The Now Playing stage's coupling with the transport. Exactly one of queue/lyrics is the active + // tab while the stage is open; opening a tab opens it and closing clears all three. NowPlaying + // reads these and asks for changes through tabRequested/closeRequested. + property bool queueOpen: false + property bool lyricsOpen: false + property bool nowPlayingOpen: false + + // The persistent right panel (Queue | Lyrics) and its active tab. Open by default on a wide + // window (>= 1400 px); hidden entirely below 1100 px, and yielded to the Now Playing stage. + property bool panelOpen: app.width >= Style.sp(350) + property string panelTab: "queue" + readonly property bool panelShown: app.panelOpen && app.width >= Style.sp(275) && !app.nowPlayingOpen + + // The navigation rail's collapse state, toggled from the title bar. + property bool sidebarOpen: true + + // The Sound dialog (tempo / pitch / reverb / bass / width). A modal Loader that only exists while + // open; SoundWorker lands chrome/SoundDialog.qml and Playback.soundRequested. + property bool soundOpen: false + + // The mini player window's visibility. shell.qml's mini PanelWindow binds its visible to this; the + // title-bar and player-bar buttons toggle it, MiniPlayer clears it. + property bool miniOpen: false + + // Any full-window modal (Sound dialog, Ctrl+K palette, Listen Together): the frame behind it is + // snapshot-blurred while one of these is open (spec 9). + readonly property bool modalOpen: app.soundOpen || palette.open || listenTogether.open + + // --- Now Playing stage helpers ---------------------------------------------------------- + function npOpenTab(tab) { + app.nowPlayingOpen = true; + app.queueOpen = tab === "queue"; + app.lyricsOpen = tab === "lyrics"; + } + function npClose() { + app.nowPlayingOpen = false; + app.queueOpen = false; + app.lyricsOpen = false; + } + function npToggleTab(tab) { + if (app.nowPlayingOpen && ((tab === "queue" && app.queueOpen) || (tab === "lyrics" && app.lyricsOpen))) + app.npClose(); + else + app.npOpenTab(tab); + } + function npToggle() { + if (app.nowPlayingOpen) + app.npClose(); + else + app.npOpenTab(app.lyricsOpen ? "lyrics" : "queue"); + } + + // --- right panel helpers ---------------------------------------------------------------- + function panelOpenTab(tab) { + app.panelTab = tab; + app.panelOpen = true; + } + function panelToggleTab(tab) { + if (app.panelShown && app.panelTab === tab) + app.panelOpen = false; + else + app.panelOpenTab(tab); + } + + // Rig hook: type into the current page's hero search (Home) without a keyboard. + function devSuggest(q) { + var pg = pageLoader.item; + var field = (pg && "heroSearch" in pg) ? pg.heroSearch : null; + if (field) { field.forceFocus(); field.value = q; } + } + + // One accent sampler per window (Canvas paints only inside a rendering window). + ArtAccent {} + + Connections { + target: Playback + function onNowPlayingRequested(tab: string): void { app.npOpenTab(tab); } + function onSoundRequested(): void { app.soundOpen = true; } + } + + // ── frame ──────────────────────────────────────────────────────────────────────────── + ColumnLayout { + id: frame + anchors.fill: parent + spacing: 0 + + // Spec-9 snapshot blur: a modal captures the whole frame behind it once (layer.live: false) + // and blurs that frozen texture, then draws its scrim + card sharp on top. No live subtree is + // ever blurred per frame; the overlays (Toast, SoundDialog, palette, menu) sit outside + // this layer, so they stay crisp. + layer.enabled: app.modalOpen + layer.live: false + layer.effect: MultiEffect { + blurEnabled: true + blur: 1.0 + blurMax: 32 + } + + TitleBar { + Layout.fillWidth: true + sidebarOpen: app.sidebarOpen + panelOpen: app.panelShown + onSidebarToggleClicked: app.sidebarOpen = !app.sidebarOpen + onPanelToggleClicked: app.panelOpen = !app.panelOpen + onSoundClicked: app.soundOpen = true + onAccountClicked: (gx, gy) => accountMenu.openAt(gx, gy) + onListenTogetherClicked: listenTogether.open = !listenTogether.open + onMiniClicked: app.miniOpen = !app.miniOpen + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Sidebar { + Layout.fillHeight: true + open: app.sidebarOpen + onCollapseRequested: app.sidebarOpen = false + } + + // The content area: the single cover Backdrop, a paper glass over it so the bloom reads + // as a subtle wash, and the routed page inset by the shared padding. The Now Playing stage + // rises over all of it (right of the rail, above the transport) when open. + Item { + id: contentArea + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + Backdrop { + anchors.fill: parent + strength: Style.paperDark ? 0.22 : 0.14 + focusX: 0.82 + focusY: 0.12 + } + Rectangle { + anchors.fill: parent + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.88) + } + // The provider's light over the glass: red for YouTube Music, green for Spotify. + ProviderGlow { anchors.fill: parent; focusX: 0.82; focusY: 0.1 } + + Item { + id: pageClip + anchors.fill: parent + anchors.leftMargin: Style.pagePad + anchors.rightMargin: Style.pagePad + anchors.topMargin: Style.sp(6) + anchors.bottomMargin: Style.pagePad + clip: true + + // The routed page. On each route change it crossfades in and rises 8 px, on the + // render thread (Animators), so the swap costs no main-thread work. The page is + // loaded by URL from its type name, so a new page lights up the moment its file + // lands; unknown routes fall through to the placeholder. + Item { + id: pageStack + width: parent.width + height: parent.height + readonly property string page: Router.current ? Router.current.page : "home" + + Loader { + id: pageLoader + anchors.fill: parent + source: { + var m = { home: "HomePage", search: "SearchPage", library: "LibraryPage", + playlist: "PlaylistPage", album: "AlbumPage", artist: "ArtistPage", list: "ListPage", + radio: "RadioPage", downloads: "DownloadsPage", settings: "SettingsPage" }; + return m[pageStack.page] ? Qt.resolvedUrl("pages/" + m[pageStack.page] + ".qml") : ""; + } + } + Loader { + anchors.fill: parent + active: pageLoader.status !== Loader.Ready + sourceComponent: placeholder + } + + ParallelAnimation { + id: enter + OpacityAnimator { target: pageStack; from: 0; to: 1; duration: Tokens.durFastEffects; easing.type: Easing.OutCubic } + YAnimator { target: pageStack; from: Style.sp(2); to: 0; duration: Tokens.durFastEffects; easing.type: Easing.OutCubic } + } + // A render-thread Animator with duration 0 never applies + // its `to`, so under Reduce motion (which zeroes the + // shared token) the page stack would be stranded at + // opacity 0 -- a blank page on every route. Land the end + // state directly when there is no animation to run. + function reenter() { + if (Tokens.durFastEffects > 0) { + enter.restart(); + } else { + enter.stop(); + pageStack.opacity = 1; + pageStack.y = 0; + } + } + onPageChanged: reenter() + Component.onCompleted: reenter() + // A provider switch swaps the catalogue under every page (a YouTube + // library is not a Spotify one), so the content re-enters the way a + // route change does while the room's light tweens to the new colour. + Connections { + target: Playback + function onProviderChanged(): void { pageStack.reenter(); } + } + } + } + + NowPlaying { + anchors.fill: parent + nowPlayingOpen: app.nowPlayingOpen + queueOpen: app.queueOpen + lyricsOpen: app.lyricsOpen + onTabRequested: (tab) => app.npOpenTab(tab) + onCloseRequested: app.npClose() + } + } + + RightPanel { + Layout.fillHeight: true + open: app.panelShown + tab: app.panelTab + onTabRequested: (tab) => app.panelOpenTab(tab) + onCloseRequested: app.panelOpen = false + } + } + + PlayerBar { + Layout.fillWidth: true + visible: !!Playback.now + panelOpen: app.panelShown + panelTab: app.panelTab + nowPlayingOpen: app.nowPlayingOpen + onToggleQueue: app.panelToggleTab("queue") + onToggleLyrics: app.panelToggleTab("lyrics") + onToggleNowPlaying: app.npToggle() + onSoundClicked: app.soundOpen = true + } + } + + Component { + id: placeholder + Item { + ColumnLayout { + anchors.centerIn: parent + spacing: Style.sp(2) + Icon { + Layout.alignment: Qt.AlignHCenter + name: "music" + size: Style.fs.hero + color: Tokens.inkFaint + } + Text { + Layout.alignment: Qt.AlignHCenter + text: (Router.current ? Router.current.page : "").toUpperCase() + color: Tokens.inkDim + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + font.letterSpacing: 2 + } + Text { + Layout.alignment: Qt.AlignHCenter + text: "This surface arrives in a later build." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + } + } + + // ── foreground layer ──────────────────────────────────────────────────────────────── + Toast { } + + // The Sound dialog: a self-contained modal (its own spec-9 snapshot blur) that only exists while + // open. The file may not exist yet — a string source only errors when the Loader activates, so + // this stays inert until SoundWorker lands it; the only wiring is its close request. + Loader { + id: soundLoader + anchors.fill: parent + active: app.soundOpen + source: "chrome/SoundDialog.qml" + } + Connections { + target: soundLoader.item + ignoreUnknownSignals: true + function onCloseRequested(): void { app.soundOpen = false; } + } + + // The Ctrl+K command palette and its global shortcut. On top of everything, so it can't be clipped + // and covers the whole frame while open. + CommandPalette { id: palette } + Shortcut { + sequences: ["Ctrl+K"] + context: Qt.WindowShortcut + onActivated: palette.open = !palette.open + } + // Escape walks the overlays back: palette, then the Now Playing stage. Keyboard actions get + // no animation of their own; the stage's own fade is all. + Shortcut { + sequences: ["Escape"] + context: Qt.WindowShortcut + enabled: palette.open || app.nowPlayingOpen + onActivated: { + if (palette.open) palette.open = false; + else app.npClose(); + } + } + + // Account menu (sign in / out via the daemon). A full-surface dismiss layer closes it. + MouseArea { + anchors.fill: parent + visible: accountMenu.visible + onClicked: accountMenu.visible = false + } + Rectangle { + id: accountMenu + visible: false + width: Style.sp(56) + implicitHeight: menuCol.implicitHeight + Style.sp(2) + height: implicitHeight + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + + function openAt(gx, gy) { + x = Math.max(Style.sp(2), Math.min(gx - width, app.width - width - Style.sp(2))); + y = gy + Style.sp(1); + visible = true; + } + + ColumnLayout { + id: menuCol + anchors.fill: parent + anchors.margins: Style.sp(1) + spacing: Style.sp(1) + + // ── YouTube Music ──────────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.margins: Style.sp(1) + spacing: Style.sp(2) + Artwork { + visible: !!(Playback.auth && Playback.auth.signedIn && Playback.auth.avatar) + url: (Playback.auth && Playback.auth.avatar) ? Playback.auth.avatar : "" + px: Style.sp(8) + round: true + placeholderIcon: "account" + } + Icon { + visible: !(Playback.auth && Playback.auth.signedIn && Playback.auth.avatar) + name: "youtube-music" + size: Style.fs.lg + color: Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + text: (Playback.auth && Playback.auth.signedIn && Playback.auth.name) + ? Playback.auth.name : "Not signed in" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: (Playback.auth && Playback.auth.signedIn) ? "YouTube Music" : "Sign in to sync your library" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + Rectangle { + id: ytAction + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: ytHover.hovered ? Tokens.tint5 : "transparent" + readonly property bool signedIn: !!(Playback.auth && Playback.auth.signedIn) + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { + name: ytAction.signedIn ? "close" : "account" + size: Style.fs.md + color: ytAction.signedIn ? Style.alert : Tokens.ink + } + Text { + Layout.fillWidth: true + text: ytAction.signedIn ? "Sign out" : "Sign in with Google" + color: ytAction.signedIn ? Style.alert : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + HoverHandler { id: ytHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + accountMenu.visible = false; + Daemon.call(ytAction.signedIn ? "sign_out" : "sign_in").catch(() => {}); + } + } + } + + Hairline { Layout.fillWidth: true } + + // ── Spotify ────────────────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.margins: Style.sp(1) + spacing: Style.sp(2) + Icon { + name: "spotify" + size: Style.fs.lg + color: (Playback.spotify && Playback.spotify.signedIn) ? Style.providerColor : Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + text: (Playback.spotify && Playback.spotify.signedIn && Playback.spotify.name) + ? Playback.spotify.name : "Not signed in" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: (Playback.spotify && Playback.spotify.signedIn) ? "Spotify" : "Premium required" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + Rectangle { + id: spAction + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: spHover.hovered ? Tokens.tint5 : "transparent" + readonly property bool signedIn: !!(Playback.spotify && Playback.spotify.signedIn) + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { + name: spAction.signedIn ? "close" : "spotify" + size: Style.fs.md + color: spAction.signedIn ? Style.alert : Tokens.ink + } + Text { + Layout.fillWidth: true + text: spAction.signedIn ? "Sign out" : "Sign in to Spotify" + color: spAction.signedIn ? Style.alert : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + HoverHandler { id: spHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + accountMenu.visible = false; + if (spAction.signedIn) Playback.spotifySignOut().catch(() => {}); + else Playback.spotifySignIn().catch(() => {}); + } + } + } + } + } + + // Listen Together session sheet: a foreground overlay toggled from the title bar. It anchors the + // whole frame and is visible only while open. + ListenTogether { id: listenTogether } + +} diff --git a/client/Daemon.qml b/client/Daemon.qml new file mode 100644 index 0000000..1bd2bb4 --- /dev/null +++ b/client/Daemon.qml @@ -0,0 +1,115 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// One connection to ryotunesd. Requests carry an id and resolve a Promise; lines without +// an id are events. A dropped connection is retried every 2 s; once a subscription is asked +// for it is re-sent on every reconnect, and every pending request is rejected on a drop so no +// page waits forever. +Singleton { + id: root + + readonly property string socketPath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/ryotunes/ryotunesd.sock" + readonly property bool connected: !!root.sock && root.sock.connected + property int protocol: 0 + property string daemonVersion: "" + + // A daemon event (no id): name plus its payload, fanned out to Playback and any surface. + signal event(string name, var data) + // A failure the daemon itself cannot record (not connected, socket dropped). `level` is + // "warn"/"error"; Logs subscribes and forwards into the diagnostics record. Emitted as a + // signal rather than calling Logs directly so the two singletons do not import each other. + signal callFailed(string level, string target, string message) + // The `subscribe` reply: the full { playback, queue, settings, auth } snapshot, delivered on + // the first subscribe and again after every reconnect so Playback resynchronises each time. + signal snapshot(var data) + + property int nextId: 1 + property var pending: ({}) + // Whether a subscription has been asked for; drives the re-subscribe on reconnect. + property bool wantSubscribe: false + property var sock: null + + function reconnect() { + if (root.connected) return; + // Quickshell retains the native socket after an initial connection error; assigning + // connected=true again does not reconnect it. A fresh Socket also clears stale framing. + if (root.sock) root.sock.destroy(); + root.sock = socketComponent.createObject(root); + root.sock.connected = true; + } + Component.onCompleted: root.reconnect() + + function call(method, params) { + return new Promise((resolve, reject) => { + if (!root.connected) { + // The daemon never sees this failure, so it can only be recorded here: a user + // clicking play against a dead daemon is a support ticket with no other trace. + root.callFailed("warn", "rpc:" + method, "ryotunesd is not connected"); + reject({ code: "disconnected", message: "ryotunesd is not connected" }); + return; + } + const id = root.nextId++; + root.pending[id] = { resolve, reject }; + sock.write(JSON.stringify({ id, method, params: params === undefined ? null : params }) + "\n"); + sock.flush(); + }); + } + + // Ask the daemon for a hello (version handshake) and the event stream with its opening + // snapshot. Idempotent: sets the intent, subscribes now if connected, and the socket re-runs + // it on every future reconnect. Returns the subscribe Promise for the immediate caller. + function subscribeAll() { + root.wantSubscribe = true; + return root._subscribe(); + } + + function _subscribe() { + if (!root.connected) + return Promise.reject({ code: "disconnected", message: "ryotunesd is not connected" }); + root.call("hello").then(h => { root.protocol = h.protocol; root.daemonVersion = h.daemon; }); + const p = root.call("subscribe", { events: ["*"] }); + p.then(s => root.snapshot(s)); + return p; + } + + function handleLine(line) { + let msg; + try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.event !== undefined) { + root.event(msg.event, msg.data); + return; + } + const p = root.pending[msg.id]; + if (!p) return; + delete root.pending[msg.id]; + if (msg.error) p.reject(msg.error); else p.resolve(msg.result); + } + + Component { + id: socketComponent + Socket { + id: connection + path: root.socketPath + parser: SplitParser { onRead: line => root.handleLine(line) } + onConnectionStateChanged: { + if (root.sock !== connection) return; + if (connection.connected) { + if (root.wantSubscribe) root._subscribe(); + } else { + root.callFailed("error", "daemon", "connection to ryotunesd lost (socket closed)"); + for (const id in root.pending) root.pending[id].reject({ code: "disconnected", message: "connection lost" }); + root.pending = {}; + } + } + } + } + Timer { + id: retry + interval: 2000 + running: !root.connected + repeat: true + onTriggered: root.reconnect() + } +} diff --git a/client/Downloads.qml b/client/Downloads.qml new file mode 100644 index 0000000..c6750bb --- /dev/null +++ b/client/Downloads.qml @@ -0,0 +1,276 @@ +pragma Singleton +import QtQuick +import Quickshell +import "lib/ids.js" as Ids + +// The client's mirror of ryotunesd's download queue and history, plus the one place a surface asks +// to save a track. Like Playback it holds no truth: the daemon owns every job, this singleton just +// reflects it. State is seeded by the `subscribe` snapshot's `downloads` block and refreshed by the +// `downloads-changed` event, so the queue page and every download button update without polling; a +// reconnect re-runs subscribe, which re-delivers the snapshot, so the mirror resynchronises on its +// own. Actions are daemon calls that surface success/failure through Playback's toast layer. +Singleton { + id: root + + // The daemon's { jobs, activeCount, queuedCount } object. Replaced whole on each update so the + // derived aliases and any binding on `jobs` re-run. + property var snapshot: ({ jobs: [], activeCount: 0, queuedCount: 0 }) + readonly property var jobs: (root.snapshot && root.snapshot.jobs) ? root.snapshot.jobs : [] + readonly property int activeCount: root.snapshot ? (root.snapshot.activeCount || 0) : 0 + readonly property int queuedCount: root.snapshot ? (root.snapshot.queuedCount || 0) : 0 + property bool loaded: false + property bool loading: false + property string error: "" + property bool addingAlbum: false + + // Which collection cards the downloads page has expanded (label -> true). Lives on the + // singleton so the page's inline components (ComponentBehavior: Bound, no outer ids) can + // toggle it; replaced whole so bindings on it re-evaluate. + property var openCollections: ({}) + function toggleCollection(label) { + var next = Object.assign({}, root.openCollections); + if (next[label]) + delete next[label]; + else + next[label] = true; + root.openCollections = next; + } + + // The daemon's persisted download preferences, fetched lazily on demand; null until loaded. The + // settings page owns editing them (it calls the daemon directly); this is an optional read for a + // surface that wants to show the destination folder. + property var settings: null + + // --- events + opening snapshot ----------------------------------------------------------- + Connections { + target: Daemon + function onEvent(name, data) { + if (name === "downloads-changed") + root.apply(data); + } + function onSnapshot(snap) { + if (snap && snap.downloads) + root.apply(snap.downloads); + else + root.refresh(); + } + } + // A late-instantiated singleton (first referenced after the opening snapshot already fired) still + // pulls the current state; on a live connection this is a no-op-cheap single call. + Component.onCompleted: if (Daemon.connected) root.refresh(); + + function apply(d) { + if (!d) + return; + root.snapshot = { + jobs: d.jobs || [], + activeCount: d.activeCount || 0, + queuedCount: d.queuedCount || 0 + }; + root.loaded = true; + root.error = ""; + } + + // Pull the queue/history from the daemon. Called on demand and whenever a reconnect snapshot + // arrives without a downloads block. Returns the Promise so a page can show a first-load state. + function refresh() { + root.loading = true; + root.error = ""; + return Daemon.call("get_downloads") + .then((d) => { root.apply(d); root.loading = false; return d; }) + .catch((e) => { + root.loading = false; + root.error = (e && e.message) ? e.message : "Could not load downloads"; + }); + } + + function loadSettings() { + return Daemon.call("get_download_settings") + .then((s) => { root.settings = s; return s; }) + .catch(() => {}); + } + + // --- track classification ---------------------------------------------------------------- + // The download source of a Playback.now-shaped track, told from its id exactly the way the + // daemon does: a strict 11-char YouTube id, a numeric SoundCloud track (sc:track:) or a + // base62 Spotify track (spotify:track:). Local files and live radio need no download; any + // other shape (a Spotify/SoundCloud album or artist id that somehow reached now-playing) is + // "unknown" and not downloadable. + function kindOf(track) { + if (!track || !track.videoId) + return "none"; + var id = String(track.videoId); + if (Ids.isLocalId(id)) + return "local"; + if (Ids.isRadioId(id)) + return "radio"; + if (/^spotify:track:[0-9A-Za-z]+$/.test(id)) + return "spotify"; + if (/^sc:track:[0-9]+$/.test(id)) + return "soundcloud"; + if (/^[0-9A-Za-z_-]{11}$/.test(id)) + return "youtube"; + return "unknown"; + } + function canDownload(track) { + var k = root.kindOf(track); + return k === "youtube" || k === "soundcloud" || k === "spotify"; + } + // A human hint for the download button's tooltip / accessible description: why a track cannot be + // saved, and the one caveat that matters when it can — a Spotify track saves a matched YouTube + // audio track, not the Spotify stream. + function reason(track) { + switch (root.kindOf(track)) { + case "none": return "Nothing is playing."; + case "local": return "This track is already a local file."; + case "radio": return "Live radio can\u2019t be downloaded."; + case "spotify": return "Saves a matching YouTube audio track."; + case "soundcloud": return "Save this SoundCloud track."; + case "youtube": return "Save this track."; + default: return "This track can\u2019t be downloaded."; + } + } + + // The job for a given video id (the newest matching record), or null. Reads `jobs`, so a binding + // that calls this re-evaluates when the queue changes. + function jobFor(videoId) { + if (!videoId) + return null; + var js = root.jobs; + for (var i = 0; i < js.length; i++) + if (js[i] && js[i].videoId === videoId) + return js[i]; + return null; + } + + // --- actions (each a daemon call) -------------------------------------------------------- + function enqueueRequest(track) { + return Daemon.call("enqueue_download", { + videoId: track.videoId, + title: track.title || "", + artists: track.artists || "", + thumbnail: track.thumbnail || "" + }); + } + + // --- collections (albums + playlists) ---------------------------------------------------- + // The batch endpoint owns smart dedup: it checks the whole collection against the download + // folder in one pass (exact name, collision suffix, or the same normalized "artist title" + // from a different upload) and against the queue, so re-downloading an album you half own + // adds only the missing tracks and the toast can say exactly what happened. + // `collection`/`collectionKind` name the album/playlist the batch came from; the daemon + // stamps them onto every job it admits so the downloads page can group them under one card. + function toEntry(song, fallbackArtist, fallbackThumb, collection, collectionKind) { + return { + videoId: song.video_id, + title: song.title, + artists: song.artists || fallbackArtist || "", + thumbnail: song.thumbnail || fallbackThumb || "", + collection: collection || "", + collectionKind: collectionKind || "" + }; + } + + // Post pre-built entries (from toEntry) as one batch. `label` names the collection in the + // progress/failure toasts. This singleton owns the operation, so navigating away cannot + // interrupt it. Entries are mapped exactly once — a second toEntry pass would read + // `video_id` off an object that already carries `videoId` and send empty rows. + function enqueueCollection(entries, label) { + Playback.toast("Preparing " + label + " downloads\u2026", "info"); + return Daemon.call("enqueue_collection", { entries: entries }).then((res) => { + var parts = []; + var added = res && res.added ? res.added : 0; + if (added) + parts.push(added + (added === 1 ? " track added" : " tracks added")); + if (res && res.alreadyDownloaded) + parts.push(res.alreadyDownloaded + " already saved"); + if (res && res.skipped) + parts.push(res.skipped + " unavailable"); + Playback.toast(parts.length ? parts.join(" \u00b7 ") : "Nothing new to download", + added ? "success" : "info"); + return res; + }); + } + + // Gather the complete album/playlist (every continuation page) before admission so the + // daemon dedups the whole collection, not just the part the UI had loaded. + function enqueueCollectionFromPage(page, label) { + if (!page || root.addingAlbum) + return; + root.addingAlbum = true; + var tokens = Object.create(null); + var tracks = (page.items || []).slice(); + + function collect(token) { + if (!token) + return Promise.resolve(); + if (tokens[token]) + return Promise.reject(new Error("Pagination repeated; no downloads were added.")); + tokens[token] = true; + return Daemon.call("get_playlist_more", { token: token }).then((more) => { + tracks = tracks.concat(more.items || []); + return collect(more.continuation); + }); + } + return collect(page.continuation).then(() => { + // Rows carry their own artist line on playlist pages; album pages may not, so fall + // back to the page's artist, then its cover, exactly as the old per-track path did. + var entries = []; + for (var i = 0; i < tracks.length; i++) + entries.push(root.toEntry(tracks[i], page.artist, page.thumbnail, page.title, label)); + return root.enqueueCollection(entries, label); + }).catch((e) => { + Playback.toast(((e && e.message) ? e.message : "Could not download " + label), "error"); + }).then((res) => { + root.addingAlbum = false; + return res; + }); + } + + function enqueueAlbum(album) { + return root.enqueueCollectionFromPage(album, "album"); + } + + // Save the given track (a Playback.now-shaped object). Immediate: a toast confirms or surfaces + // the daemon's error, and the queue updates through the downloads-changed event. A track that + // cannot be downloaded is reported rather than sent. + function enqueue(track) { + if (!track || !track.videoId) + return; + if (!root.canDownload(track)) { + Playback.toast(root.reason(track), "info"); + return; + } + root.enqueueRequest(track) + .then((job) => { + var done = job && job.status === "completed"; + Playback.toast(done ? "Already downloaded" : "Added to downloads", "success"); + }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not start download", "error")); + } + function cancel(id) { + return Daemon.call("cancel_download", { id: id }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not cancel", "error")); + } + function retry(id) { + return Daemon.call("retry_download", { id: id }) + .then((job) => job) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not retry", "error")); + } + // Reveal a completed download's file in its folder. + function open(id) { + return Daemon.call("open_download", { id: id }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not open file", "error")); + } + // Open the configured download folder. + function openFolder() { + return Daemon.call("open_download_folder") + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not open folder", "error")); + } + // Remove terminal records (completed/failed/cancelled) from history. Never touches saved files. + function clearHistory() { + return Daemon.call("clear_download_history") + .then(() => Playback.toast("Download history cleared", "success")) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not clear history", "error")); + } +} diff --git a/client/Logs.qml b/client/Logs.qml new file mode 100644 index 0000000..fd95b14 --- /dev/null +++ b/client/Logs.qml @@ -0,0 +1,99 @@ +pragma Singleton +import QtQuick +import Quickshell + +// The client's side of the diagnostics record. Every failure the UI can see — a rejected RPC, +// a lost daemon connection, a playback stop, a QML binding error — goes through root.error()/ +// root.warn(). While the daemon is reachable the entry is forwarded straight to `log_client_error` +// (it lands in the same ring + file as daemon entries, tagged `client`); while it is not, entries +// buffer here (bounded) and flush on the next successful connection, because "the daemon died" +// is exactly the failure whose evidence must survive. A short window around each burst collapses +// repeats of the same message so a retry loop cannot flood the log. +Singleton { + id: root + + readonly property int maxBuffer: 200 + property var buffer: [] + + // Last-forwarded key + time, for the repeat window. + property string lastKey: "" + property real lastAt: 0 + + Connections { + target: Daemon + function onConnectedChanged(): void { + if (Daemon.connected) + root.flush(); + } + function onCallFailed(level, target, message) { + root._record(level, target, message); + } + } + + // Every failure the user actually sees passes through the toast layer: local validation + // ("sign in first"), daemon events (cover-error, playback stops) and surfaced RPC errors. + // Recording them means a screenshot of a toast is never the only evidence of a bug. + Connections { + target: Playback + function onToast(message, kind) { + if (kind === "error") + root._record("error", "toast", message); + } + } + function warn(target, message) { + root._record("warn", target, message); + } + function error(target, message) { + root._record("error", target, message); + } + + function _record(level, target, message) { + var text = String(message === undefined ? "" : message); + if (text === "") + text = "unknown failure"; + // Collapse an exact repeat of the same level/target/message inside 3 s. + var key = level + "\u0000" + target + "\u0000" + text; + var now = Date.now(); + if (key === root.lastKey && now - root.lastAt < 3000) + return; + root.lastKey = key; + root.lastAt = now; + + var entry = { level: level, target: target, message: text }; + if (Daemon.connected) { + Daemon.call("log_client_error", entry).catch(function () { + root._buffer(entry); + }); + } else { + root._buffer(entry); + } + } + + function _buffer(entry) { + var next = root.buffer.slice(); + next.push(entry); + if (next.length > root.maxBuffer) + next = next.slice(next.length - root.maxBuffer); + root.buffer = next; + } + + // Deliver everything buffered while the daemon was down. One failure to post stops the + // flush (the rest stay buffered for the next connection); entries carry their own text, so + // a daemon that is flapping still accumulates them locally. + function flush() { + if (root.buffer.length === 0) + return; + var pending = root.buffer; + root.buffer = []; + var chain = Promise.resolve(); + for (var i = 0; i < pending.length; i++) { + (function (entry) { + chain = chain.then(function () { + return Daemon.call("log_client_error", entry); + }).catch(function () { + root._buffer(entry); + }); + })(pending[i]); + } + } +} diff --git a/client/Personal.qml b/client/Personal.qml new file mode 100644 index 0000000..2da75b6 --- /dev/null +++ b/client/Personal.qml @@ -0,0 +1,238 @@ +pragma Singleton +import QtQuick +import Quickshell +import "lib/personal.js" as P +import "lib/ids.js" as Ids + +// The shared personal store, mirrored from the daemon (AppState::{personal,set_personal}). It moved +// off the Svelte app's browser localStorage so a native client can read it too. `get_personal` seeds +// the mirror on every (re)subscribe and the `personal-changed` event keeps it live, so a second +// client follows along. Mutations are held until hydration, then persisted immediately +// through serialized writes. All shape logic stays in lib/personal.js. +Singleton { + id: root + + // The whole blob, always a full personal object, plus the slices Home reads. + property var blob: P.empty() + readonly property var picks: (root.blob && root.blob.picks) ? root.blob.picks : [] + readonly property var pins: (root.blob && root.blob.pins) ? root.blob.pins : [] + readonly property var homeArrange: (root.blob && root.blob.home) ? root.blob.home + : ({ order: [], hidden: [], seen: [] }) + + // Recording state kept out of the persisted blob. `lastNowId` is the last track we recorded a + // play for, so a repeated now-playing for the same track (a metadata refresh) or a reconnect — + // which replays state through the snapshot, never a now-playing event — is not a fresh play. + // `wasSignedIn`/`wasSpotifyIn` remember each session's state so only a real sign-out transition + // sweeps its recents. `pendingSaves` holds the blobs of set_personal writes still awaiting their + // personal-changed echo, so the daemon's echo of our own save can be dropped instead of applied. + property string lastNowId: "" + property bool wasSignedIn: false + property bool wasSpotifyIn: false + property var pendingSaves: [] + property bool hydrated: false + property bool dirty: false + property bool saving: false + property int revision: 0 + property int reloadId: 0 + property var deferredMutations: [] + + // The "Jump back in" rail: recents newest-first, minus anything already a shortcut (otherwise + // the two lists converge on the same handful of items in two shapes), capped at nine — three + // full columns once the window is filtered. + function recent(n) { + var pinned = ({}); + for (var i = 0; i < root.picks.length; i++) + pinned[root.picks[i].id] = true; + var out = P.recentItems(root.blob, 100).filter(function (r) { return !pinned[r.id]; }); + return out.slice(0, n === undefined ? 9 : n); + } + function topArtistIds(n) { return P.topArtistIds(root.blob, n); } + function firstArtist(s) { return P.firstArtist(s); } + function isPinned(id) { return root.pins.indexOf(id) >= 0; } + // Order feed sections by the user's saved Home arrangement (identity while it is empty). + function arrange(sections) { return P.arrangeSections(sections, root.blob); } + + // --- live sync from the daemon ----------------------------------------------------------- + Connections { + target: Daemon + function onEvent(name, data) { + if (name === "personal-changed") { + // The daemon echoes every set_personal back to us. Applying our own echo is a no-op + // at best and, if a mutation landed in the few ms since the save, reverts it — so + // drop the echo. A genuine change from another client never matches and still applies. + if (root.consumeSelfEcho(data)) + return; + root.apply(data); + } else if (name === "now-playing") { + root.onNowPlaying(data); + } + } + // Fired on the subscribe reply (first connect and every reconnect) — reload the store then. + function onSnapshot(snap) { root.reload(); } + } + // Seed the sign-out watchers from the current sessions (a singleton created after the auth + // snapshot would otherwise miss the transition), then mirror the store if already connected. + Component.onCompleted: { + root.wasSignedIn = !!(Playback.auth && Playback.auth.signedIn); + root.wasSpotifyIn = !!(Playback.spotify && Playback.spotify.signedIn); + if (Daemon.connected) root.reload(); + } + + function reload() { + var request = ++root.reloadId; + var revision = root.revision; + Daemon.call("get_personal") + .then(function (r) { + if (request !== root.reloadId) + return; + if (!root.hydrated || root.revision === revision) + root.apply(r ? r.personal : null); + root.save(); + }) + .catch(function () {}); + } + + // Replace the mirror from a daemon blob (a get_personal result or a personal-changed payload). + // No account sweep here: purging must ride the real sign-out transition, not an initial + // hydration. The auth snapshot can still hold its default signed-out value when the first blob + // lands, and sweeping then would wrongly erase recents belonging to a session that is signed in. + function apply(b) { + if (root.hydrated && (root.dirty || root.saving)) + return; + root.blob = P.hydrate(b); + root.revision++; + root.hydrated = true; + var deferred = root.deferredMutations; + root.deferredMutations = []; + for (var i = 0; i < deferred.length; i++) + root.mutate(deferred[i]); + } + + // A track started playing: record it as a recent song, refresh any matching shortcut's recency + // and count its artist, as player.svelte.ts does on `now-playing`. Guarded on a genuine track + // change so a repeated event for the same track, or a reconnect (which replays through the + // snapshot, never this event), is not counted as a new play. Radio carries no navigable song, + // shortcut or artist page. + function onNowPlaying(n) { + if (!n || !n.videoId || Ids.isRadioId(n.videoId)) { + root.lastNowId = ""; + return; + } + if (n.videoId === root.lastNowId) + return; + root.lastNowId = n.videoId; + root.mutate(function (b) { + var now = Date.now(); + var song = { kind: "song", id: n.videoId, title: n.title || "", + subtitle: n.artists || "", thumbnail: n.thumbnail || "" }; + if (n.artistId) song.artistId = n.artistId; + P.noteRecent(b, song, now); + P.touchPick(b, n.videoId, now); + if (n.artists) + P.noteArtist(b, n.artistId ? n.artistId : n.artists, P.firstArtist(n.artists), now); + return true; + }); + } + + // --- reducers: mutate a clone, publish, persist ------------------------------------------ + // A JSON clone so the reassignment is a new object (QML change detection) and the pure reducer + // never aliases the live blob's nested recent/artists maps. + function mutate(fn) { + if (!root.hydrated) { + root.deferredMutations = root.deferredMutations.concat([fn]); + return true; + } + var b = JSON.parse(JSON.stringify(root.blob)); + if (fn(b) === false) + return false; + root.blob = b; + root.revision++; + root.dirty = true; + root.save(); + return true; + } + + // Add to Shortcuts (evicting the tile gone longest unplayed when the grid is full). Returns + // false when it was already there. This is the call the pages' pin controls make. + function addPick(item) { return root.mutate(function (b) { return P.addPick(b, item, Date.now()); }); } + function removePick(id) { return root.mutate(function (b) { P.removePick(b, id); return true; }); } + function touchPick(id) { return root.mutate(function (b) { return P.touchPick(b, id, Date.now()); }); } + function seedPick(item) { return root.mutate(function (b) { return P.seedPick(b, item, Date.now()); }); } + function noteRecent(item) { return root.mutate(function (b) { P.noteRecent(b, item, Date.now()); return true; }); } + // Signing out of an account drops only the recents that belong to *that* session (Home's "Jump + // back in" must not keep offering a library the daemon can no longer fetch), and only on a real + // signed-in -> signed-out transition — never on the initial snapshot, whose signed-out default + // would otherwise erase a signed-in session's history. The two providers are independent. + Connections { + target: Playback + function onAuthChanged(): void { + var nowIn = !!(Playback.auth && Playback.auth.signedIn); + if (root.wasSignedIn && !nowIn) + root.mutate(function (b) { return P.forgetYouTubeRecents(b) > 0; }); + root.wasSignedIn = nowIn; + } + function onSpotifyChanged(): void { + var nowIn = !!(Playback.spotify && Playback.spotify.signedIn); + if (root.wasSpotifyIn && !nowIn) + root.mutate(function (b) { return P.forgetSpotifyRecents(b) > 0; }); + root.wasSpotifyIn = nowIn; + } + } + + // pin / unpin, returning "pinned" | "unpinned" | "full" like personal.ts togglePin. + function togglePin(id) { + var result = ""; + root.mutate(function (b) { + result = P.togglePin(b, id); + return result !== "full"; // a refused pin changed nothing + }); + return result; + } + function pin(id) { return root.isPinned(id) ? "pinned" : root.togglePin(id); } + function unpin(id) { if (root.isPinned(id)) root.togglePin(id); } + + // Recognise (and drop) the personal-changed echo of a set_personal we sent. The daemon echoes + // each save back in the order it received them, so the oldest pending blob is the one to match; + // deepEqual ignores serde's key reordering. Anything else is a real change from another client. + function consumeSelfEcho(blob) { + for (var i = 0; i < root.pendingSaves.length; i++) { + if (P.deepEqual(root.pendingSaves[i], blob)) { + var pending = root.pendingSaves.slice(); + pending.splice(i, 1); + root.pendingSaves = pending; + return true; + } + } + return false; + } + function dropPendingSave(snap) { + var i = root.pendingSaves.indexOf(snap); + if (i >= 0) { + var arr = root.pendingSaves.slice(); + arr.splice(i, 1); + root.pendingSaves = arr; + } + } + + // No close-time debounce gap and no overlapping full-blob writes. A new mutation + // during an in-flight save is sent as soon as that save acknowledges. + function save() { + if (!root.hydrated || !root.dirty || root.saving || !Daemon.connected) + return; + var snap = root.blob; + root.pendingSaves = root.pendingSaves.concat([snap]); + root.saving = true; + root.dirty = false; + Daemon.call("set_personal", { personal: snap }) + .then(function () { + root.saving = false; + root.save(); + }) + .catch(function () { + root.dropPendingSave(snap); + root.saving = false; + root.dirty = true; + Playback.toast("Could not save your listening history and shortcuts", "error"); + }); + } +} diff --git a/client/Playback.qml b/client/Playback.qml new file mode 100644 index 0000000..c2c3ee1 --- /dev/null +++ b/client/Playback.qml @@ -0,0 +1,167 @@ +pragma Singleton +import QtQuick +import Quickshell +import "lib/playback.js" as PB + +// The client's only playback state: a mirror of the daemon, never a source of truth. Every event +// from Daemon is applied by lib/playback.js applyEvent (ported from player.svelte.ts); every method +// is a daemon call. The opening snapshot from `subscribe` seeds the same properties. Optimistic UI +// is confined to `seekDrag` (the held seek thumb) and `volDrag` (the held volume slider). +Singleton { + id: root + + // --- mirrored state ---------------------------------------------------------------------- + property var now: null + // The playing cover's sampled accent (components/ArtAccent); transparent until sampled. + property color artAccent: "transparent" + property var queue: ({ items: [], currentIndex: 0 }) + property real position: 0 + property real duration: 0 + property bool paused: false + property int volume: 100 + property bool stopAfterCurrent: false + property string rating: "indifferent" + property var pendingVideoId: null + property var lastError: null + property var lyrics: ({ synced: false, lines: [] }) + property var auth: ({ signedIn: false }) + property var settings: ({}) + property var lt: ({ role: "none" }) + // The Sound dialog's effects, mirrored from the daemon (the `audio-fx` event and the subscribe + // snapshot). `bass` is dB, reverb/width are 0..1, speed is the tempo multiplier. + property var audioFx: ({ speed: 1, semitones: 0, reverb: 0, bass: 0, width: 0 }) + // The active music provider ("youtube" | "spotify" | "soundcloud"), mirrored from the daemon + // (the subscribe snapshot's `provider` and a `provider-changed` event). The title bar's switch + // reads it; each catalogue's own sign-in state gates its shelves, not the selector. + property string provider: "youtube" + // The Spotify account, mirrored from the daemon (the subscribe snapshot's `spotify` object and + // the `spotify-auth` events). `signedIn` gates the Spotify catalogue; `premium` is null until the + // profile is known. Never a source of truth — every field lands from the daemon. + property var spotify: ({ signedIn: false, stored: false, name: null, premium: null }) + // The SoundCloud account, mirrored the same way (the snapshot's `soundcloud` object and the + // `soundcloud-auth` events). SoundCloud browses and plays as a guest; signing in only adds + // the account's own shelves (playlists, likes, following) to Home. + property var soundcloud: ({ signedIn: false, name: null, error: "" }) + + // --- SoundCloud waveform cache ----------------------------------------------------------- + // The playing track's 240 amplitude samples (get_waveform), for the Orange seek bar; null for a + // non-SoundCloud track or before it resolves. Cached per video id so switching back to a track + // (or reopening the stage) never refetches. Only SoundCloud ids ("sc:") carry a waveform. + property var waveform: null + property var waveformCache: ({}) + + // --- optimistic drag state --------------------------------------------------------------- + // NaN when the seek thumb is not held; a number pins the shown position and suppresses the + // daemon's position echoes until release. + property real seekDrag: NaN + // The position the UI should render: the held thumb while dragging, else the live sample. + readonly property real shownPosition: isNaN(root.seekDrag) ? root.position : root.seekDrag + // True while the volume slider is held, so a volume echo cannot yank the thumb backwards. + property bool volDrag: false + + // A message the daemon surfaced (error/notice/cover-error/lt-notice), for the toast layer. + signal toast(string message, string kind) + // A page asking the App to open the Now Playing overlay on a tab ("queue" | "lyrics"). + signal nowPlayingRequested(string tab) + // A surface (player-bar tools, track menu) asking the App to open the Sound dialog. + signal soundRequested() + + // --- events + opening snapshot ----------------------------------------------------------- + Connections { + target: Daemon + function onEvent(name, data) { + var fx = PB.applyEvent(root, name, data); + if (fx) { + if (fx.toast) root.toast(fx.toast, fx.kind); + // The spotify-auth url step asks the browser to open the OAuth page. + if (fx.openUrl) Quickshell.execDetached(["xdg-open", fx.openUrl]); + } + } + function onSnapshot(snap) { root.loadSnapshot(snap); } + } + + // Seed state from the { playback, queue, settings, auth } reply the daemon sends on subscribe + // (and re-sends after every reconnect), the socket equivalent of frontend_ready's resync. + function loadSnapshot(snap) { + if (!snap) return; + if (snap.queue) root.queue = snap.queue; + var pb = snap.playback; + if (pb) { + root.volume = pb.volume; // before the now guard: the slider is stale either way + if (!root.now) { // a real now-playing event may have beaten the snapshot + root.now = pb.now; + root.rating = (pb.now && pb.now.rating) ? pb.now.rating : "indifferent"; + root.paused = pb.paused; + root.duration = pb.duration; + root.position = PB.clampPosition(pb.duration, pb.position); + root.stopAfterCurrent = pb.stopAfterCurrent || false; + } + } + if (snap.settings) root.settings = snap.settings; + if (snap.auth) root.auth = { signedIn: !!snap.auth.signedIn, name: snap.auth.name, avatar: snap.auth.avatar }; + if (snap.audioFx) root.audioFx = snap.audioFx; + if (snap.provider) root.provider = snap.provider; + if (snap.spotify) + root.spotify = { signedIn: !!snap.spotify.signedIn, stored: !!snap.spotify.stored, + name: snap.spotify.name, premium: snap.spotify.premium }; + if (snap.soundcloud) + root.soundcloud = { signedIn: !!snap.soundcloud.signedIn, + name: snap.soundcloud.name, error: "" }; + } + + // --- SoundCloud waveform ----------------------------------------------------------------- + // On every track change resolve a SoundCloud track's waveform (get_waveform) into the per-id + // cache and publish it as `waveform`; a non-SoundCloud track clears it. A cached id is served + // straight away and never refetched. onNowChanged also covers the opening snapshot's `now`. + onNowChanged: root.ensureWaveform(root.now ? root.now.videoId : null) + function ensureWaveform(vid) { + if (!vid || String(vid).indexOf("sc:") !== 0) { + root.waveform = null; + return; + } + if (root.waveformCache[vid]) { + root.waveform = root.waveformCache[vid]; + return; + } + root.waveform = null; + Daemon.call("get_waveform", { id: vid }) + .then((r) => { + var s = (r && r.samples) ? r.samples : []; + root.waveformCache[vid] = s; + if (root.now && root.now.videoId === vid) + root.waveform = s; + }) + .catch(() => {}); + } + + // --- methods (each a daemon call) -------------------------------------------------------- + function play(item) { return Daemon.call("play", { item: item }); } + function playIndex(i) { return Daemon.call("play_index", { index: i }); } + function togglePause() { return Daemon.call("toggle_pause"); } + function next() { return Daemon.call("next_track"); } + function prev() { return Daemon.call("prev_track"); } + function seek(secs) { return Daemon.call("seek", { position: secs }); } + function setVolume(v) { return Daemon.call("set_volume", { volume: v }); } + // Send the Sound dialog's effects to the daemon; its audio-fx echo updates root.audioFx. + function setAudioFx(fx) { + return Daemon.call("set_audio_fx", { speed: fx.speed, semitones: fx.semitones, reverb: fx.reverb, bass: fx.bass, width: fx.width }); + } + // Optimistic: the switch flips the local mirror at once and asks the daemon. The daemon method + // lands later, so a failure is swallowed rather than surfaced. + function setProvider(p) { + root.provider = p; + return Daemon.call("set_provider", { provider: p }).catch(() => {}); + } + // Start the Spotify OAuth flow; the daemon spawns the task and drives the rest through + // spotify-auth events (the url step opens the browser). Idempotent while a flow is running. + function spotifySignIn() { return Daemon.call("spotify_sign_in"); } + function spotifySignOut() { return Daemon.call("spotify_sign_out"); } + function soundcloudSignIn() { return Daemon.call("soundcloud_sign_in"); } + function soundcloudSignOut() { return Daemon.call("soundcloud_sign_out"); } + function toggleShuffle() { return Daemon.call("toggle_shuffle"); } + // off -> all -> one -> off, matching player.svelte.ts cycleRepeat. + function cycleRepeat() { + var r = (root.queue && root.queue.repeat) ? root.queue.repeat : "off"; + return Daemon.call("set_repeat", { mode: r === "off" ? "all" : r === "all" ? "one" : "off" }); + } +} diff --git a/client/Prefs.qml b/client/Prefs.qml new file mode 100644 index 0000000..411b785 --- /dev/null +++ b/client/Prefs.qml @@ -0,0 +1,38 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Client-local preferences: what the daemon has no UI_SETTINGS key for and no other client needs. +// One JSON file, written atomically on every change through the adapter (the Ryoku shell's +// shell.json pattern). Absent or malformed reads as the defaults below. +Singleton { + id: root + + property alias skin: adapter.skin // "system" | a skin id (see Skin.qml, docs/SKINS.md) + property alias decor: adapter.decor // "skin" | "rich" | "calm": the decor level, or the skin's own + property alias themeMode: adapter.themeMode // "system" | "light" | "dark" + // The mini widget's offset from the screen's bottom-right corner, logical px. + property alias miniRight: adapter.miniRight + property alias miniBottom: adapter.miniBottom + + function save() { file.writeAdapter(); } + + FileView { + id: file + path: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/ryotunes/client.json" + blockLoading: true + watchChanges: true + printErrors: false + atomicWrites: true + onFileChanged: reload() + JsonAdapter { + id: adapter + property string skin: "system" + property string decor: "skin" + property string themeMode: "system" + property int miniRight: 24 + property int miniBottom: 24 + } + } +} diff --git a/client/Router.qml b/client/Router.qml new file mode 100644 index 0000000..480fae8 --- /dev/null +++ b/client/Router.qml @@ -0,0 +1,47 @@ +pragma Singleton +import QtQuick +import Quickshell + +// The page stack: push/pop/replace and a remembered scroll offset per entry, so back-navigation +// lands where you left. The whole array is replaced on each mutation (never patched in place) so +// bindings on `stack`/`current`/`canGoBack` re-run. Entries are { page, params, scrollY }. +Singleton { + id: root + + property var stack: [{ page: "home", params: ({}), scrollY: 0 }] + readonly property var current: root.stack.length ? root.stack[root.stack.length - 1] : null + readonly property bool canGoBack: root.stack.length > 1 + + function entry(page, params) { + return { page: page, params: params === undefined ? ({}) : params, scrollY: 0 }; + } + + function push(page, params) { + var s = root.stack.slice(); + s.push(root.entry(page, params)); + root.stack = s; + } + + function pop() { + if (root.stack.length <= 1) return; + var s = root.stack.slice(); + s.pop(); + root.stack = s; + } + + function replace(page, params) { + var s = root.stack.slice(); + s[s.length - 1] = root.entry(page, params); + root.stack = s; + } + + // Persist the live scroll position of the current entry (called as a page scrolls) so it can be + // restored when the user comes back to it. + function setScroll(y) { + if (!root.stack.length) return; + var s = root.stack.slice(); + var top = s[s.length - 1]; + s[s.length - 1] = { page: top.page, params: top.params, scrollY: y }; + root.stack = s; + } +} diff --git a/client/Skin.qml b/client/Skin.qml new file mode 100644 index 0000000..9cce939 --- /dev/null +++ b/client/Skin.qml @@ -0,0 +1,303 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons + +// The skin: every colour, type face, radius and duration the chrome reads, resolved from ONE +// source the user picks (Prefs.skin): +// +// "system" Ryoku's live theme through Tokens (the wallpaper palette or the named scheme, +// shell.json's font and decor) - the default on a Ryoku desktop. +// a skin directory `//skin.json`, looked up in order: +// $RYOTUNES_SKIN_DIRS (colon list, dev) > ~/.config/ryotunes/skins > /../skins +// so a user skin shadows a shipped one of the same id. `matugen` is just a skin +// whose file matugen writes (matugen/ryotunes.json is the template). +// +// A skin file is watched: editing it re-applies live, which is how skins get authored. Colours +// are applied the way the old light/dark pins were - as a Material scheme on Tokens.namedScheme +// - so every surface that reads Tokens re-renders at once with no per-file plumbing. Type, shape, +// motion, decor, accent policy and wash come straight from here through Style. docs/SKINS.md is +// the format reference; skins/skin.schema.json the schema. +Singleton { + id: root + + // --- paths ------------------------------------------------------------------------------- + readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config") + readonly property string dataHome: Quickshell.env("XDG_DATA_HOME") || (Quickshell.env("HOME") + "/.local/share") + readonly property string userDir: configHome + "/ryotunes/skins" + // What RyoStore installs (category `ryotunes-skins`, one folder per product with a receipt). + readonly property string storeDir: dataHome + "/ryoku/ryotunes-skins" + readonly property string shippedDir: Quickshell.shellDir + "/../skins" + readonly property var devDirs: (Quickshell.env("RYOTUNES_SKIN_DIRS") || "").split(":").filter((d) => d.length > 0) + // Search order; first hit per id wins: a user's own copy shadows a store install, which + // shadows a shipped skin of the same id. + readonly property var dirs: root.devDirs.concat([root.userDir, root.storeDir, root.shippedDir]) + + // RyoStore on this box (Ryoku): Settings offers "Get more skins" only then. + property bool storeAvailable: false + Process { + command: ["sh", "-c", "command -v ryostore >/dev/null 2>&1 && echo yes || echo no"] + running: true + stdout: StdioCollector { onStreamFinished: root.storeAvailable = this.text.trim() === "yes" } + } + + // --- selection --------------------------------------------------------------------------- + // RYOTUNES_SKIN pins a skin for a preview run (scripts/dev/skin-preview.sh); Prefs otherwise. + readonly property string forced: Quickshell.env("RYOTUNES_SKIN") || "" + readonly property string id: root.forced || Prefs.skin || "system" + readonly property bool followSystem: root.id === "system" + + // --- catalogue --------------------------------------------------------------------------- + // [{ id, name, author, description, version, source: "shipped"|"store"|"user"|"dev", generated, + // dir, path, manifest }] in search order, one entry per id. + property var all: [] + property string error: "" // last load/parse problem, for Settings + function byId(id) { + for (var i = 0; i < root.all.length; i++) + if (root.all[i].id === id) + return root.all[i]; + return null; + } + readonly property var entry: root.byId(root.id) + readonly property var paper: root.byId("paper") + + // The built-in fallback: the paper skin's values, so the chrome renders even with no skins + // directory at all (a bare checkout run with `qs -p client`). + readonly property var fallback: ({ + id: "paper", name: "Paper", "default": "dark", + modes: { + dark: { paper: "#050505", paperLift: "#0d0d0c", ink: "#d7cfc6", inkDim: "#b6aea5", + bone: "#d7cfc6", inkOnBone: "#090807", sun: "#e2342a", alert: "#d33b32" }, + light: { paper: "#c8c4bc", paperLift: "#d5d0c7", ink: "#211f1c", inkDim: "#403b35", + bone: "#292620", inkOnBone: "#eee8de", sun: "#e2342a", alert: "#d33b32" } + }, + type: { display: "Fraunces", ui: "Space Grotesk", mono: "SpaceMono Nerd Font" }, + shape: { radius: 8, radiusCard: 12 }, + motion: { snap: 90, move: 170, swap: 210, slow: 200 }, + decor: "rich", accent: "artwork", wash: 1.0, fonts: [] + }) + readonly property var paperManifest: (root.paper && root.paper.manifest) ? root.paper.manifest : root.fallback + // The active manifest: the chosen skin, or paper for "system" (its modes are what Light/Dark + // pin to while following the desktop). + readonly property var manifest: (root.entry && root.entry.manifest) ? root.entry.manifest : root.paperManifest + + // --- mode -------------------------------------------------------------------------------- + // Prefs.themeMode "light"/"dark" pins a mode; "system" means the skin's own default, or, for + // the system skin, whatever the desktop is (no pin at all). + // RYOTUNES_SKIN_MODE pins the mode for a preview run (so a light-first skin renders light + // whatever the user's own pin is); Prefs otherwise. + readonly property string wantMode: Quickshell.env("RYOTUNES_SKIN_MODE") || Prefs.themeMode || "system" + readonly property string mode: root.wantMode !== "system" ? root.wantMode + : (root.followSystem ? "system" : (root.manifest["default"] || "dark")) + readonly property bool unpinned: root.followSystem && root.mode === "system" + // The skin lacks the pinned mode: its other mode is used and Settings says so. + readonly property bool modeMissing: !root.unpinned && !(root.manifest.modes && root.manifest.modes[root.mode]) + function palette(m, source) { + var modes = (source && source.modes) ? source.modes : {}; + if (modes[m]) return modes[m]; + return modes[m === "dark" ? "light" : "dark"] || root.fallback.modes.dark; + } + // The palette painted right now: the skin's mode, or Tokens' live roles while unpinned. + readonly property var colors: { + if (root.unpinned) { + return { paper: Tokens.paper, paperLift: Tokens.paperLift, ink: Tokens.ink, inkDim: Tokens.inkDim, + bone: Tokens.bone, inkOnBone: Tokens.inkOnBone, sun: Tokens.sun, alert: root.fallback.modes.dark.alert }; + } + var p = root.palette(root.mode, root.manifest); + return Object.assign({}, root.fallback.modes[root.mode === "light" ? "light" : "dark"], p); + } + + // --- the rest of the skin (type, shape, motion, decor, accent, wash) ---------------------- + function pick(section, key, base) { + var s = root.manifest[section]; + if (s && s[key] !== undefined && s[key] !== null && s[key] !== "") return s[key]; + var f = root.fallback[section]; + return (f && f[key] !== undefined) ? f[key] : base; + } + // Type is the skin's, never the desktop's: shell.json's fontFamily is the bar's face (often a + // mono Nerd font) and would put the whole app in it. The system skin keeps Paper's type. + readonly property var type: ({ display: root.pick("type", "display"), ui: root.pick("type", "ui"), mono: root.pick("type", "mono") }) + readonly property var shape: ({ radius: root.pick("shape", "radius"), radiusCard: root.pick("shape", "radiusCard") }) + // Durations pass through Tokens' motion gate (reduce motion, the motion scale) like Ryoku's own. + readonly property var motion: { + var r = Tokens.reduceMotion, s = Tokens.motionScale; // bind + function d(ms) { return r ? 0 : Math.round(ms * s); } + if (root.followSystem) + return { snap: Tokens.snap, move: Tokens.move, swap: Tokens.swap, slow: Tokens.durDefaultEffects }; + return { snap: d(root.pick("motion", "snap")), move: d(root.pick("motion", "move")), + swap: d(root.pick("motion", "swap")), slow: d(root.pick("motion", "slow")) }; + } + readonly property string decor: root.followSystem ? Tokens.decor : (root.manifest.decor || root.fallback.decor) + // "artwork" (the playing cover's colour), "provider" (the catalogue's brand colour), "sun" + // (the skin's own primary), or a fixed "#rrggbb". + readonly property string accent: root.manifest.accent || root.fallback.accent + // A multiplier on the cover wash behind pages and the stage; 0 keeps the paper flat. + readonly property real wash: (typeof root.manifest.wash === "number") ? Math.max(0, Math.min(2, root.manifest.wash)) : 1.0 + + // --- applying colours through Tokens ------------------------------------------------------- + // The same Material scheme the old light/dark pins wrote; `__skin` marks it as ours so an + // overwrite by Tokens.refreshNamed (a shell.json edit) is recognised and re-applied. + function scheme(p) { + return { + __skin: root.id + "/" + root.mode, + surface: p.paper, + surfaceContainerLow: p.paperLift, + onSurface: p.ink, + onSurfaceVariant: p.inkDim, + inverseSurface: p.bone, + inverseOnSurface: p.inkOnBone, + primary: p.sun + }; + } + function apply() { + if (root.unpinned) { + if (Tokens.namedScheme && Tokens.namedScheme.__skin) + Tokens.refreshNamed(); + return; + } + var want = root.scheme(root.palette(root.mode, root.manifest)); + var have = Tokens.namedScheme; + if (have && have.__skin === want.__skin && have.surface === want.surface && have.onSurface === want.onSurface + && have.primary === want.primary && have.surfaceContainerLow === want.surfaceContainerLow) + return; + Tokens.namedScheme = want; + } + onIdChanged: root.apply() + onModeChanged: root.apply() + onManifestChanged: root.apply() + Connections { + target: Tokens + // shell.json changed: Tokens re-read its named palette over ours - put the skin back. + function onNamedSchemeChanged(): void { if (!root.unpinned) root.apply(); } + } + + // --- discovery --------------------------------------------------------------------------- + // One shell pass lists every //skin.json in search order and streams each file + // between a record separator (path) and a unit separator (contents); no jq, no python. + function reload() { lister.running = true; } + Process { + id: lister + command: ["sh", "-c", + 'for d in "$@"; do for f in "$d"/*/skin.json; do [ -f "$f" ] || continue; printf "\\036%s\\037" "$f"; cat "$f"; done; done', + "sh"].concat(root.dirs) + stdout: StdioCollector { onStreamFinished: root.ingest(this.text) } + } + function ingest(text) { + var out = [], seen = {}, err = ""; + var recs = text.split("\u001e"); + for (var i = 1; i < recs.length; i++) { + var cut = recs[i].indexOf("\u001f"); + if (cut < 0) continue; + var path = recs[i].slice(0, cut); + var body = recs[i].slice(cut + 1); + var dir = path.slice(0, path.lastIndexOf("/")); + var dirId = dir.slice(dir.lastIndexOf("/") + 1); + var m = null; + try { m = JSON.parse(body); } catch (e) { err = path + ": " + e; continue; } + if (!m || typeof m !== "object" || !m.modes) { err = path + ": not a skin (no modes)"; continue; } + var id = (typeof m.id === "string" && m.id.length) ? m.id : dirId; + if (id !== dirId) { err = path + ": id \"" + id + "\" does not match its folder \"" + dirId + "\""; continue; } + if (seen[id]) continue; // an earlier (higher-precedence) dir already provided it + seen[id] = true; + var source = root.devDirs.some((d) => path.indexOf(d + "/") === 0) ? "dev" + : (path.indexOf(root.userDir + "/") === 0 ? "user" + : (path.indexOf(root.storeDir + "/") === 0 ? "store" : "shipped")); + out.push({ + id: id, name: m.name || id, author: m.author || "", description: m.description || "", + version: m.version || "", source: source, generated: m.generated || "", + dir: dir, path: path, manifest: m + }); + } + root.all = out; + root.error = err; + if (!root.followSystem && !root.byId(root.id)) + root.error = "skin \"" + root.id + "\" is not installed; showing Paper"; + } + + // The active skin's file, watched: a save in an editor re-applies it live. + FileView { + id: activeFile + path: (root.entry && root.entry.path) ? root.entry.path : "" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + if (!root.entry) return; + try { + var m = JSON.parse(activeFile.text()); + if (!m || !m.modes) return; + var next = root.all.slice(); + for (var i = 0; i < next.length; i++) + if (next[i].id === root.entry.id) { + next[i] = Object.assign({}, next[i], { manifest: m, name: m.name || next[i].id, + author: m.author || "", description: m.description || "", version: m.version || "" }); + break; + } + root.all = next; + root.error = ""; + } catch (e) { + root.error = root.entry.path + ": " + e; + } + } + } + + // A skin's bundled faces (`"fonts": ["fonts/Foo.ttf"]`, paths relative to its folder). + Instantiator { + model: (root.entry && root.manifest.fonts) ? root.manifest.fonts : [] + delegate: FontLoader { + id: face + required property var modelData + source: "file://" + root.entry.dir + "/" + face.modelData + } + } + + // The store's library and the user dir are watched as directories: a RyoStore install or + // remove, or a folder dropped in by hand, rescans without a manual Reload. FileView on a + // directory reports changes to its entry list; the debounce folds a multi-file install into + // one scan. + FileView { path: root.storeDir; watchChanges: true; printErrors: false; onFileChanged: rescan.restart() } + FileView { path: root.userDir; watchChanges: true; printErrors: false; onFileChanged: rescan.restart() } + Timer { id: rescan; interval: 400; onTriggered: root.reload() } + + // ~/.config/ryotunes/skins exists from the first run, so "Open skins folder" and a matugen + // template have somewhere to land. + Process { + id: mkuser + command: ["mkdir", "-p", root.userDir] + running: true + onExited: (code, status) => root.reload() + } + + // Fork the painted palette into a new user skin the author can edit live. + // Returns the manifest path; Settings opens it. + function forkCurrent(newId) { + var m = JSON.parse(JSON.stringify(root.manifest)); + m.id = newId; m.name = newId; m.author = Quickshell.env("USER") || ""; m.version = "0.1.0"; + m.description = "Forked from " + (root.followSystem ? "the desktop theme" : root.manifest.name || root.id); + delete m.generated; delete m["$schema"]; + m.format = 1; + var painted = root.colors; + var modeKey = root.unpinned ? (Tokens.light ? "light" : "dark") : root.mode; + m.modes = m.modes || {}; + m.modes[modeKey] = { + paper: String(painted.paper), paperLift: String(painted.paperLift), ink: String(painted.ink), + inkDim: String(painted.inkDim), bone: String(painted.bone), inkOnBone: String(painted.inkOnBone), + sun: String(painted.sun), alert: String(painted.alert) + }; + m["default"] = modeKey; + m.type = root.type; m.shape = root.shape; + var dir = root.userDir + "/" + newId; + forkWriter.dir = dir; + forkWriter.body = JSON.stringify(m, null, 2) + "\n"; + forkWriter.running = true; + return dir + "/skin.json"; + } + Process { + id: forkWriter + property string dir + property string body + command: ["sh", "-c", 'mkdir -p "$1" && printf "%s" "$2" > "$1/skin.json"', "sh", forkWriter.dir, forkWriter.body] + onExited: (code, status) => root.reload() + } +} diff --git a/client/Spectrum.qml b/client/Spectrum.qml new file mode 100644 index 0000000..1e70f9a --- /dev/null +++ b/client/Spectrum.qml @@ -0,0 +1,84 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// The playback spectrum: cava on the PipeWire monitor, 40 bands at 30 fps, the Ryoku shell's +// AudioBars pattern. A surface that draws it claims the feed with claim(id, true) while it is +// visible and releases with claim(id, false); the analyser runs only while at least one surface +// claims it AND Style.live holds (something is playing). Power-saver no longer gates it: a +// visualizer the user is looking at is content, not decoration. +// levels settle flat when frames stop (silence, a restart gap, the gate closing). +Singleton { + id: root + + property var owners: [] + readonly property bool claimed: owners.length > 0 + function claim(id, on) { + var next = owners.filter(o => o !== id); + if (on) next.push(id); + owners = next; + } + + readonly property bool analysing: root.claimed && Style.live + readonly property int bands: 40 + readonly property int fps: 30 + + property var levels: root.flat() + property real energy: 0 + property real lastReadMs: 0 + + function flat() { + var a = []; + for (var i = 0; i < root.bands; i++) a.push(0); + return a; + } + + Process { + id: cava + command: ["sh", "-c", "command -v cava >/dev/null 2>&1 || exit 0; cfg=\"${XDG_RUNTIME_DIR:-/tmp}/ryotunes-cava.conf\"; printf '%s\\n' '[general]' 'framerate = " + root.fps + "' 'bars = " + root.bands + "' '' '[input]' 'method = pipewire' 'source = auto' '' '[output]' 'method = raw' 'raw_target = /dev/stdout' 'data_format = ascii' 'ascii_max_range = 100' 'channels = mono' 'mono_option = average' '' '[smoothing]' 'noise_reduction = 45' > \"$cfg\"; exec cava -p \"$cfg\""] + // Bound, never assigned (an imperative write would detach it from the gate). + running: root.analysing && !cava.backoff + property bool backoff: false + stdout: SplitParser { + splitMarker: "\n" + onRead: (line) => root.readBars(line) + } + onExited: if (root.analysing) { + cava.backoff = true; + restartTimer.restart(); + } + } + Timer { id: restartTimer; interval: 1200; onTriggered: cava.backoff = false } + + Timer { + interval: 120 + running: root.analysing + repeat: true + onTriggered: if (Date.now() - root.lastReadMs > 260) { + root.levels = root.flat(); + root.energy = 0; + } + } + onAnalysingChanged: { + levels = flat(); + energy = 0; + if (analysing) lastReadMs = 0; + } + + function readBars(line) { + var t = line.trim(); + if (!t) return; + var parts = t.split(/[;\s]+/); + if (parts.length < root.bands) return; + var out = [], sum = 0; + for (var i = 0; i < root.bands; i++) { + var n = parseInt(parts[i]); + var v = isNaN(n) ? 0 : Math.max(0, Math.min(1, n / 100)); + out.push(v); sum += v; + } + root.levels = out; + root.energy = sum / root.bands; + root.lastReadMs = Date.now(); + } +} diff --git a/client/Style.qml b/client/Style.qml new file mode 100644 index 0000000..d4b38b2 --- /dev/null +++ b/client/Style.qml @@ -0,0 +1,149 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons +import "lib/style.js" as Fns + +// The app's own scale plus everything the active Skin decides: radii, type, motion, the accent +// policy and the cover wash. Colours reach every surface through Ryoku's Tokens (Skin pins its +// palette there, or leaves Tokens to the desktop for the system skin). thumb()/fmtTime() are +// re-exposed from lib/style.js so a QtTest process, which cannot load Quickshell, can test the +// same code. +Singleton { + id: root + + // Follows whatever scale the active window pins on Tokens (Tokens.uiScaleFor(screen)); 1 until + // a window sets it. sp(n) is the 4 px grid step scaled to match. + readonly property real uiScale: Tokens.uiScale + function sp(n) { return Math.round(n * 4 * root.uiScale); } + + // Geometry (docs/superpowers/specs/2026-09-05-native-client-visual-design.md, section 2). + readonly property int radius: Math.round(Skin.shape.radius * root.uiScale) // controls + readonly property int radiusCard: Math.round(Skin.shape.radiusCard * root.uiScale) // cards, hero art + readonly property int rowH: Math.round(52 * root.uiScale) // table rows + readonly property int ctlH: Math.round(36 * root.uiScale) // buttons, fields + readonly property int heroArt: Math.round(168 * root.uiScale) // page hero art, card + readonly property int cardW: Math.round(168 * root.uiScale) + readonly property int sidebarW: Math.round(248 * root.uiScale) + readonly property int panelW: Math.round(340 * root.uiScale) + readonly property int titleBarH: Math.round(44 * root.uiScale) + readonly property int playerBarH: Math.round(88 * root.uiScale) + readonly property int pagePad: Math.round(32 * root.uiScale) + + readonly property string fontUi: Skin.type.ui + readonly property string fontMono: Skin.type.mono + readonly property string fontCjk: "Noto Sans CJK JP" + readonly property string fontDisplay: Skin.type.display + + // Type roles (px at uiScale 1). At most four per screen. micro is the tracked mono label + // (letterSpacing 1.4); xl/title/hero are Fraunces. + readonly property var fs: ({ + micro: Math.round(10 * root.uiScale), + xs: Math.round(11 * root.uiScale), + sm: Math.round(13 * root.uiScale), + md: Math.round(15 * root.uiScale), + lg: Math.round(18 * root.uiScale), + xl: Math.round(24 * root.uiScale), + title: Math.round(36 * root.uiScale), + hero: Math.round(44 * root.uiScale) + }) + readonly property real trackMicro: 1.4 + + // Durations (ms) from the skin, already through Ryoku's motion gate (reduce motion, the + // motion scale). snap: hover/press; move: a selector travelling; swap: content exchanging; + // slow: a panel or page. + readonly property var motion: Skin.motion + + // --- ambient motion and the power gate ----------------------------------------------------- + // Passive animation (bloom drift, LIVE breath, the spectrum) runs only while something plays, + // motion is not reduced, and the machine is not in power-saver. The profile is polled from + // powerprofilesctl every 30 s; a missing tool reads as "not saving". + property bool powerSaver: false + readonly property bool ambient: !!Playback.now && !Playback.paused && !Tokens.reduceMotion && !root.powerSaver + // The spectrum is content the user asked to see (a visualizer on a card, the stage's ribbon), + // not decoration: it runs whenever something plays and a surface claims it, power profile + // aside. It still stops the moment nothing is playing or nothing shows it. + readonly property bool live: !!Playback.now && !Playback.paused + Process { + id: profileProbe + command: ["sh", "-c", "command -v powerprofilesctl >/dev/null 2>&1 && powerprofilesctl get || echo balanced"] + stdout: StdioCollector { onStreamFinished: root.powerSaver = this.text.trim() === "power-saver" } + } + Timer { interval: 30000; running: true; repeat: true; triggeredOnStart: true; onTriggered: profileProbe.running = true } + + // The one MultiEffect blur the Now Playing wash spends. shell.services Perf (Perf.blurDisabled) + // is not importable under `qs -p client`, so this is the plan's fallback gate: a bool defaulting + // true that a surface checks before enabling its blur. + readonly property bool blurEnabled: true + + // --- theme mode ------------------------------------------------------------------------- + // Owned by Skin: Prefs.skin picks the palette source, Prefs.themeMode pins light/dark. + // The warning/error colour is the skin's (Tokens.alert is a constant). + readonly property color alert: Skin.colors.alert + // The cover wash multiplier a Backdrop applies to its strength. + readonly property real wash: Skin.wash + + // --- artwork accent ----------------------------------------------------------------------- + // The one saturated colour the chrome borrows: the playing cover's accent (sampled by + // components/ArtAccent into Playback.artAccent) while a track plays, the wallpaper's primary + // otherwise. Progress fills, the live meter, active chips and the mini's glow read this. + // Sonora's derivation keeps the cover's hue but pins saturation to 0.6-0.85 and the lightness + // to 0.72 on dark paper / 0.42 on light, so the accent always reads against the surface. + readonly property bool paperDark: (Tokens.paper.r + Tokens.paper.g + Tokens.paper.b) / 3 < 0.5 + // The provider's own colour, the one clear tell of which catalogue is on: YouTube Music's red, + // Spotify's green, SoundCloud's orange. It tints the room's light (Backdrop's glow, the mini's + // glow), the provider pill and the accent's fallback when nothing plays, so switching provider + // changes the mood of the whole window at once. + readonly property var providerColors: ({ spotify: "#1db954", soundcloud: "#ff5500", youtube: "#ff2d2d" }) + readonly property color providerColor: root.providerColors[Playback.provider] || root.providerColors.youtube + // The skin's accent policy: "artwork" (the cover, provider colour when nothing plays), + // "provider", "sun" (the skin's own primary) or a fixed "#rrggbb". + readonly property color accent: { + var policy = Skin.accent; + if (policy === "provider") + return root.providerColor; + if (policy === "sun") + return Tokens.sun; + if (typeof policy === "string" && policy.charAt(0) === "#") + return policy; + var c = Playback.artAccent; + if (!Playback.now || c.a <= 0) + return root.providerColor; + return Qt.hsla(c.hslHue < 0 ? 0 : c.hslHue, Math.max(0.6, Math.min(0.85, c.hslSaturation)), root.paperDark ? 0.72 : 0.42, 1); + } + readonly property color accentDeep: Qt.hsla(accent.hslHue < 0 ? 0 : accent.hslHue, accent.hslSaturation, root.paperDark ? 0.44 : 0.5, 1) + readonly property color accentSoft: Qt.rgba(accent.r, accent.g, accent.b, 0.16) + + // --- decor level -------------------------------------------------------------------------- + // Ryoku's calm / rich switch, owned by this client (Prefs.decor) rather than the desktop's + // hubDecor: Tokens re-reads shell.json on every change and would revert it, so the level is + // re-applied whenever Tokens moves. + // Prefs.decor "skin" follows the skin's own level; "rich"/"calm" override it. + readonly property bool decorRich: (Prefs.decor === "skin" ? Skin.decor : Prefs.decor) === "rich" + function applyDecor() { + var want = root.decorRich ? "rich" : "calm"; + if (Tokens.decor !== want) + Tokens.decor = want; + } + function applyPrefs() { + root.applyDecor(); + Skin.apply(); + } + Connections { + target: Tokens + function onDecorChanged(): void { root.applyDecor(); } + } + Connections { + target: Prefs + function onDecorChanged(): void { root.applyDecor(); } + } + Connections { + target: Skin + function onDecorChanged(): void { root.applyDecor(); } + } + + function thumb(url, px) { return Fns.thumb(url, px); } + function fmtTime(secs) { return Fns.fmtTime(secs); } + function fmtCount(v) { return Fns.fmtCount(v); } +} diff --git a/client/chrome/CommandPalette.qml b/client/chrome/CommandPalette.qml new file mode 100644 index 0000000..682992c --- /dev/null +++ b/client/chrome/CommandPalette.qml @@ -0,0 +1,224 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/search.js" as Search +import "../lib/browse.js" as Browse + +// The Ctrl+K command palette, ported from CommandPalette.svelte. A modal typeahead over the same +// search_all → previewMix rows the field shows; Up/Down move, Enter takes the row (or, at the tail, +// "All results" routes to the full search page), Escape closes. Opened by the App's global shortcut. +// The modal card sits over a scrim; the page behind is frozen and blurred by App while any modal is +// open (spec 9's snapshot blur is a `layer.live:false` pass on the content, owned by App). +Item { + id: root + + anchors.fill: parent + visible: root.open + z: 400 + + property bool open: false + property string query: "" + property var items: [] + property bool loading: false + property int active: 0 + + onOpenChanged: { + if (root.open) { + root.query = ""; + root.items = []; + root.active = 0; + input.forceActiveFocus(); + } + } + + // "All results" sits at the tail when the query is long enough. + readonly property bool hasAll: root.query.trim().length >= 2 + readonly property int rowCount: root.items.length + (root.hasAll ? 1 : 0) + + function openItem(item) { + if (item.kind === "song") + Playback.play(Browse.asSong(item)); + else + Router.push(item.kind, { id: item.id, title: item.title }); + } + function choose(i) { + if (root.hasAll && i === root.items.length) { + Router.push("search", { q: root.query.trim() }); + root.open = false; + return; + } + var item = root.items[i]; + if (!item) + return; + root.openItem(item); + root.open = false; + } + function runQuery() { + var q = root.query.trim().replace(/\s+/g, " "); + if (q.length < 2) { + root.items = []; + root.loading = false; + return; + } + root.loading = true; + var reqFor = q; + Daemon.call("search_all", { query: q }) + .then((res) => { + if (root.query.trim().replace(/\s+/g, " ") !== reqFor) + return; + root.items = Search.previewMix(res, 12); + root.active = 0; + root.loading = false; + }) + .catch(() => root.loading = false); + } + + Timer { id: debounce; interval: 150; onTriggered: root.runQuery() } + onQueryChanged: { + if (root.query.trim().length < 2) { debounce.stop(); root.items = []; return; } + root.loading = true; + debounce.restart(); + } + + // Scrim over the (App-blurred) page; a click outside the card dismisses. + Rectangle { anchors.fill: parent; color: "#000000"; opacity: 0.45 } + MouseArea { anchors.fill: parent; onClicked: root.open = false } + + // The modal card. + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Style.sp(20) + width: Style.sp(160) + implicitHeight: paletteCol.implicitHeight + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: paletteCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + // field + RowLayout { + Layout.fillWidth: true + Layout.margins: Style.sp(3) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.lg; color: Tokens.inkMuted } + TextInput { + id: input + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + text: root.query + onTextChanged: root.query = text + onAccepted: root.choose(root.active) + Keys.onPressed: (e) => { + if (e.key === Qt.Key_Down) { if (root.rowCount) root.active = (root.active + 1) % root.rowCount; e.accepted = true; } + else if (e.key === Qt.Key_Up) { if (root.rowCount) root.active = (root.active - 1 + root.rowCount) % root.rowCount; e.accepted = true; } + else if (e.key === Qt.Key_Escape) { root.open = false; e.accepted = true; } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text.length === 0 + text: "Search everything…" + color: Tokens.inkFaint + font: input.font + } + } + } + + Hairline { Layout.fillWidth: true } + + Text { + Layout.fillWidth: true + Layout.margins: Style.sp(3) + visible: root.query.trim().length < 2 + text: "Type to search." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Text { + Layout.fillWidth: true + Layout.margins: Style.sp(3) + visible: root.query.trim().length >= 2 && !root.loading && root.items.length === 0 + text: "Nothing quick for that." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + + ListView { + id: palList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, Style.sp(120)) + visible: root.items.length > 0 + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: root.items + delegate: Rectangle { + id: pRow + required property var modelData + required property int index + width: palList.width + implicitHeight: Style.sp(11) + color: (root.active === pRow.index) ? Tokens.tint10 : pHover.hovered ? Tokens.tint5 : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(2) + Artwork { + url: pRow.modelData.thumbnail ? pRow.modelData.thumbnail : "" + px: Style.sp(10) + round: pRow.modelData.kind === "artist" + placeholderIcon: pRow.modelData.kind === "artist" ? "user" : pRow.modelData.kind === "album" ? "cd" : pRow.modelData.kind === "playlist" ? "playlist" : "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { Layout.fillWidth: true; text: pRow.modelData.title; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; elide: Text.ElideRight } + Text { Layout.fillWidth: true; text: pRow.modelData.subtitle ? pRow.modelData.subtitle : pRow.modelData.kind; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; elide: Text.ElideRight } + } + Text { text: pRow.modelData.kind.toUpperCase(); color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + } + HoverHandler { id: pHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; hoverEnabled: true; onEntered: root.active = pRow.index; onClicked: root.choose(pRow.index) } + } + } + + // "All results" + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(11) + visible: root.hasAll + color: (root.active === root.items.length) ? Tokens.tint10 : allHover.hovered ? Tokens.tint5 : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.md; color: Tokens.inkMuted } + Text { Layout.fillWidth: true; text: "All results for \u201C" + root.query.trim() + "\u201D"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + Icon { name: "arrow-right"; size: Style.fs.sm; color: Tokens.inkMuted } + } + HoverHandler { id: allHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.choose(root.items.length) } + } + } + } +} diff --git a/client/chrome/ListenTogether.qml b/client/chrome/ListenTogether.qml new file mode 100644 index 0000000..4a1ff12 --- /dev/null +++ b/client/chrome/ListenTogether.qml @@ -0,0 +1,484 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The Listen Together session sheet, ported from ui/src/lib/components/ListenTogether.svelte. A modal +// overlay over the whole app frame, bound to App's own `open` flag. It is a pure view of Playback.lt +// (the daemon's lt-state snapshot, applied in lib/playback.js) and drives every mutation through the +// lt_* daemon methods. An invite bundles the server URL and room code as `RYO~` +// so a guest pastes one thing. No timers: the live session is entirely event-driven from the daemon. +Item { + id: root + anchors.fill: parent + visible: root.open + + property bool open: false + property string mode: "join" // "join" | "host" + property string uname: "" + property string serverUrl: "" + property string inviteInput: "" + property bool busy: false + + readonly property var lt: Playback.lt + readonly property bool inRoom: !!(root.lt && root.lt.role && root.lt.role !== "none") + readonly property bool isHost: !!(root.lt && root.lt.role === "host") + readonly property bool waiting: !!(root.lt && root.lt.requesting && root.lt.role === "none") + readonly property string invite: root.makeInvite((root.lt && root.lt.serverUrl) ? root.lt.serverUrl : "", + (root.lt && root.lt.roomCode) ? root.lt.roomCode : "") + + onOpenChanged: { + if (root.open) { + root.uname = (Playback.auth && Playback.auth.name) ? String(Playback.auth.name).trim() : ""; + root.serverUrl = (Playback.lt && Playback.lt.serverUrl) ? Playback.lt.serverUrl : ""; + root.inviteInput = ""; + root.busy = false; + } + } + + function makeInvite(server, code) { return "RYO~" + Qt.btoa(server + "|" + code); } + function parseInvite(raw) { + var s = raw.trim(); + if (s.indexOf("RYO~") === 0) { + try { + var parts = Qt.atob(s.slice(4)).split("|"); + return { server: parts[0] || "", code: (parts[1] || "").toUpperCase() }; + } catch (e) { + return null; + } + } + return { server: "", code: s.toUpperCase() }; + } + + function host() { + if (!root.uname.trim()) { Playback.toast("Enter a name first", "error"); return; } + var u = root.serverUrl.trim(); + if (!u) { Playback.toast("Enter your sync server URL", "error"); return; } + if (!/^wss?:\/\//i.test(u)) { Playback.toast("Use a ws:// or wss:// sync server address", "error"); return; } + root.busy = true; + var cur = (root.lt && root.lt.serverUrl) ? root.lt.serverUrl : ""; + var p = (u !== cur) ? Daemon.call("lt_set_server_url", { url: u }) : Promise.resolve(); + p.then(() => Daemon.call("lt_create_room", { username: root.uname.trim() })) + .then(() => { root.busy = false; }) + .catch((e) => { root.busy = false; Playback.toast((e && e.message) ? e.message : String(e), "error"); }); + } + function join() { + if (!root.uname.trim()) { Playback.toast("Enter a name first", "error"); return; } + var parsed = root.parseInvite(root.inviteInput); + if (!parsed || !parsed.code) { Playback.toast("Paste the invite code your friend sent", "error"); return; } + var cur = (root.lt && root.lt.serverUrl) ? root.lt.serverUrl : ""; + var server = parsed.server || cur; + if (!server) { Playback.toast("Paste the full invite from the host, it carries the server address", "error"); return; } + if (!/^wss?:\/\//i.test(server)) { Playback.toast("The invite does not contain a valid sync server", "error"); return; } + root.busy = true; + var p = (server !== cur) ? Daemon.call("lt_set_server_url", { url: server }) : Promise.resolve(); + p.then(() => Daemon.call("lt_join_room", { code: parsed.code, username: root.uname.trim() })) + .then(() => { root.busy = false; }) + .catch((e) => { root.busy = false; Playback.toast((e && e.message) ? e.message : String(e), "error"); }); + } + function leave() { Daemon.call("lt_leave").catch(() => {}); } + function copyInvite() { + clip.text = root.invite; + clip.selectAll(); + clip.copy(); + Playback.toast("Invite copied, send it to a friend", "success"); + } + + // Hidden holder that owns the system-clipboard copy of the invite string. + TextEdit { id: clip; visible: false; width: 0; height: 0 } + + // Scrim over the (App-blurred) page; a click outside the sheet dismisses. The page behind is + // frozen and blurred by App while a modal is open (spec 9's snapshot blur is a layer.live:false + // pass on the content, owned by App). + Rectangle { anchors.fill: parent; color: "#000000"; opacity: 0.45 } + MouseArea { anchors.fill: parent; onClicked: root.open = false } + + Rectangle { + id: sheet + anchors.centerIn: parent + width: Math.min(parent.width - Style.sp(12), Style.sp(170)) + height: Math.min(parent.height - Style.sp(12), Math.max(Style.sp(120), bodyCol.implicitHeight + head.height + Style.sp(6))) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + MouseArea { anchors.fill: parent } // swallow clicks so the dismiss layer doesn't close it + + // header + Item { + id: head + anchors { left: parent.left; right: parent.right; top: parent.top } + height: Style.sp(16) + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(4) + anchors.rightMargin: Style.sp(4) + anchors.topMargin: Style.sp(3) + spacing: 1 + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { text: "力"; color: Tokens.inkMuted; font.family: Tokens.jp; font.pixelSize: Style.fs.sm } + Text { + text: "SESSION / SYNC · LT-01" + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 + } + Item { Layout.fillWidth: true } + IconButton { icon: "close"; iconSize: Style.fs.md; diameter: Style.sp(7); onClicked: root.open = false } + } + Text { text: "Listen Together"; color: Tokens.ink; font.family: Style.fontDisplay; font.pixelSize: Style.fs.lg } + } + Hairline { anchors { left: parent.left; right: parent.right; bottom: parent.bottom } } + } + + Flickable { + id: scroll + anchors { left: parent.left; right: parent.right; top: head.bottom; bottom: parent.bottom } + anchors.margins: Style.sp(4) + clip: true + contentWidth: width + contentHeight: bodyCol.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: bodyCol + anchors { left: parent.left; right: parent.right; top: parent.top } + spacing: Style.sp(2) + + // ── waiting for host / connecting ────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: root.waiting + spacing: Style.sp(1) + Text { text: "// SESSION / HANDSHAKE"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 } + Text { + Layout.fillWidth: true + text: (root.lt && root.lt.status === "connecting") ? "Connecting to the sync transport." : "Waiting for host approval." + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.DemiBold; wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + text: "The music engine stays local until the session is accepted." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Pill { Layout.topMargin: Style.sp(1); label: "Cancel request"; onClicked: root.leave() } + } + + // ── setup (join / host) ──────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: !root.waiting && !root.inRoom + spacing: Style.sp(2) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Chip { text: "01 JOIN"; active: root.mode === "join"; onClicked: root.mode = "join" } + Chip { text: "02 HOST"; active: root.mode === "host"; onClicked: root.mode = "host" } + Item { Layout.fillWidth: true } + } + + // JOIN + ColumnLayout { + Layout.fillWidth: true + visible: root.mode === "join" + spacing: Style.sp(2) + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "INVITE"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1 } + Text { Layout.fillWidth: true; text: "Paste the code your friend sent. It already carries the server address."; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; wrapMode: Text.WordWrap } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: inviteField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: inviteField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink; font.family: Style.fontMono; font.pixelSize: Style.fs.md + text: root.inviteInput + onTextChanged: root.inviteInput = text + onAccepted: root.join() + Text { anchors.verticalCenter: parent.verticalCenter; visible: inviteField.text.length === 0; text: "RYO~…"; color: Tokens.inkFaint; font: inviteField.font } + } + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "NAME"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1 } + Text { Layout.fillWidth: true; text: "Shown to the other listeners in this room."; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; wrapMode: Text.WordWrap } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: joinNameField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: joinNameField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md + text: root.uname + onTextChanged: root.uname = text + onAccepted: root.join() + Text { anchors.verticalCenter: parent.verticalCenter; visible: joinNameField.text.length === 0; text: "Your name"; color: Tokens.inkFaint; font: joinNameField.font } + } + } + } + Pill { Layout.topMargin: Style.sp(1); label: root.busy ? "Connecting…" : "Join session"; icon: "group"; primary: true; enabled: !root.busy; onClicked: root.join() } + } + + // HOST + ColumnLayout { + Layout.fillWidth: true + visible: root.mode === "host" + spacing: Style.sp(2) + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "SYNC SERVER"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1 } + Text { Layout.fillWidth: true; text: "Your self-hosted WebSocket endpoint. It is remembered on this machine."; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; wrapMode: Text.WordWrap } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: serverField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: serverField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink; font.family: Style.fontMono; font.pixelSize: Style.fs.md + text: root.serverUrl + onTextChanged: root.serverUrl = text + onAccepted: root.host() + Text { anchors.verticalCenter: parent.verticalCenter; visible: serverField.text.length === 0; text: "wss://relay.example.org/ws"; color: Tokens.inkFaint; font: serverField.font } + } + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "NAME"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1 } + Text { Layout.fillWidth: true; text: "The host identity other listeners will see."; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; wrapMode: Text.WordWrap } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: hostNameField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: hostNameField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md + text: root.uname + onTextChanged: root.uname = text + onAccepted: root.host() + Text { anchors.verticalCenter: parent.verticalCenter; visible: hostNameField.text.length === 0; text: "Your name"; color: Tokens.inkFaint; font: hostNameField.font } + } + } + } + Pill { Layout.topMargin: Style.sp(1); label: root.busy ? "Starting…" : "Start session"; icon: "group"; primary: true; enabled: !root.busy; onClicked: root.host() } + } + } + + // ── in room ──────────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: root.inRoom + spacing: Style.sp(2) + + // room state + invite + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { text: "// LIVE SESSION"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 } + Item { Layout.fillWidth: true } + Text { + text: (root.isHost ? "HOST · " : "GUEST · ") + ((root.lt && root.lt.status) ? String(root.lt.status).toUpperCase() : "") + color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2.5) + anchors.rightMargin: Style.sp(1) + spacing: Style.sp(2) + Text { + Layout.fillWidth: true + text: root.invite + color: Tokens.inkDim; font.family: Style.fontMono; font.pixelSize: Style.fs.sm; elide: Text.ElideRight + } + Pill { label: "Copy invite"; icon: "link"; onClicked: root.copyInvite() } + } + } + // now playing in the room + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: !!(root.lt && root.lt.currentTrack) + spacing: Style.sp(2) + Artwork { + url: (root.lt && root.lt.currentTrack && root.lt.currentTrack.thumbnail) ? root.lt.currentTrack.thumbnail : "" + px: Style.sp(12) + placeholderIcon: "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { text: "NOW PLAYING"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Text { + Layout.fillWidth: true + text: (root.lt && root.lt.currentTrack) ? root.lt.currentTrack.title : "" + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.DemiBold; elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: (root.lt && root.lt.currentTrack) ? root.lt.currentTrack.artist : "" + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; elide: Text.ElideRight + } + } + } + } + + // join requests (host only) + ColumnLayout { + Layout.fillWidth: true + visible: root.isHost && !!(root.lt && root.lt.pendingJoins && root.lt.pendingJoins.length) + spacing: Style.sp(1) + Text { + text: "// JOIN REQUESTS · " + ((root.lt && root.lt.pendingJoins) ? root.lt.pendingJoins.length : 0) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 + } + Repeater { + model: (root.lt && root.lt.pendingJoins) ? root.lt.pendingJoins : [] + delegate: RowLayout { + id: joinRow + required property var modelData + Layout.fillWidth: true + spacing: Style.sp(2) + Text { text: "?"; color: Tokens.sun; font.family: Style.fontMono; font.pixelSize: Style.fs.md } + Text { Layout.fillWidth: true; text: joinRow.modelData.username; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + IconButton { icon: "check-circle"; iconSize: Style.fs.md; diameter: Style.sp(7); onClicked: Daemon.call("lt_approve_join", { userId: joinRow.modelData.userId }).catch(() => {}) } + IconButton { icon: "close"; iconSize: Style.fs.md; diameter: Style.sp(7); onClicked: Daemon.call("lt_reject_join", { userId: joinRow.modelData.userId }).catch(() => {}) } + } + } + } + + // listeners + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + text: "// LISTENERS · " + ((root.lt && root.lt.users) ? root.lt.users.length : 0) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 + } + Repeater { + model: (root.lt && root.lt.users) ? root.lt.users : [] + delegate: RowLayout { + id: userRow + required property var modelData + readonly property bool isMe: !!(root.lt && root.lt.myId && userRow.modelData.user_id === root.lt.myId) + Layout.fillWidth: true + spacing: Style.sp(2) + opacity: userRow.modelData.is_connected ? 1 : 0.5 + Text { text: userRow.modelData.is_connected ? "●" : "○"; color: userRow.modelData.is_connected ? Tokens.sun : Tokens.inkFaint; font.pixelSize: Style.fs.sm } + Text { + text: userRow.modelData.username + (userRow.isMe ? " · YOU" : "") + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight + } + Text { text: userRow.modelData.is_host ? "HOST" : "LISTENER"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Item { Layout.fillWidth: true } + IconButton { + visible: root.isHost && !userRow.isMe + icon: "account"; iconSize: Style.fs.sm; diameter: Style.sp(7) + onClicked: Daemon.call("lt_transfer_host", { userId: userRow.modelData.user_id }).catch(() => {}) + } + IconButton { + visible: root.isHost && !userRow.isMe + icon: "close"; iconSize: Style.fs.sm; diameter: Style.sp(7) + onClicked: Daemon.call("lt_kick", { userId: userRow.modelData.user_id }).catch(() => {}) + } + } + } + } + + // suggestions (host only) + ColumnLayout { + Layout.fillWidth: true + visible: root.isHost && !!(root.lt && root.lt.suggestions && root.lt.suggestions.length) + spacing: Style.sp(1) + Text { + text: "// SUGGESTIONS · " + ((root.lt && root.lt.suggestions) ? root.lt.suggestions.length : 0) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; font.letterSpacing: 1.5 + } + Repeater { + model: (root.lt && root.lt.suggestions) ? root.lt.suggestions : [] + delegate: RowLayout { + id: sugRow + required property var modelData + Layout.fillWidth: true + spacing: Style.sp(2) + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { Layout.fillWidth: true; text: sugRow.modelData.track.title; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + Text { Layout.fillWidth: true; text: sugRow.modelData.track.artist + " · from " + sugRow.modelData.from_username; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; elide: Text.ElideRight } + } + IconButton { icon: "check-circle"; iconSize: Style.fs.md; diameter: Style.sp(7); onClicked: Daemon.call("lt_approve_suggestion", { id: sugRow.modelData.id }).catch(() => {}) } + IconButton { icon: "close"; iconSize: Style.fs.md; diameter: Style.sp(7); onClicked: Daemon.call("lt_reject_suggestion", { id: sugRow.modelData.id }).catch(() => {}) } + } + } + } + + // footer + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Text { + text: "SYNC · " + ((root.lt && root.lt.status) ? String(root.lt.status).toUpperCase() : "") + color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + } + Item { Layout.fillWidth: true } + Pill { visible: !root.isHost; label: "Re-sync"; icon: "on-repeat"; onClicked: Daemon.call("lt_request_sync").catch(() => {}) } + Pill { label: "Leave"; onClicked: root.leave() } + } + } + } + } + } +} diff --git a/client/chrome/LyricsPanel.qml b/client/chrome/LyricsPanel.qml new file mode 100644 index 0000000..ac39b1a --- /dev/null +++ b/client/chrome/LyricsPanel.qml @@ -0,0 +1,589 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/playback.js" as PB + +// Synced lyrics, ported from ui/src/lib/components/LyricsView.svelte. It self-fetches get_lyrics on +// track change (guarded against stale responses by requestedId), lays the timed lines in a reused +// ListView, and follows the song: the active line is binary-searched from a local clock and centred +// with an animated contentY, click-to-seek jumps both playback and the view, and a 67 ms word timer +// karaoke-fills the active line. The timer is the one place this view spends CPU, so it runs only +// while the surface is visible, playback is not paused, and the track actually carries word timing — +// close the panel or pause and it stops (verified in Task 6 / Task 9). Manual scrolling pauses the +// auto-follow for five seconds. `compact` shrinks the type and drops the footer for the mini player. +Item { + id: root + + property bool compact: false + + // --- fetched state ---------------------------------------------------------------------- + property var lyrics: null + property bool loading: false + property string requestedId: "" + + // Per-track manual timing correction (ms; positive = lyrics appear later). Kept in memory and + // reset on every track change — a listening preference, not YouTube metadata. + property int offsetMs: 0 + + readonly property bool synced: !!(root.lyrics && root.lyrics.synced) + readonly property bool instrumental: !!(root.lyrics && root.lyrics.instrumental) + readonly property bool hasWordTiming: { + if (!root.synced || !root.lyrics.lines) + return false; + for (var i = 0; i < root.lyrics.lines.length; i++) { + var w = root.lyrics.lines[i].words; + if (w && w.length) + return true; + } + return false; + } + + // The timed cue index, rebuilt only when the document changes; a long lyric is then binary- + // searched against the clock instead of linearly rescanned 15 times a second. + readonly property var timedLines: { + var out = []; + if (!root.synced || !root.lyrics.lines) + return out; + for (var i = 0; i < root.lyrics.lines.length; i++) { + var t = root.lyrics.lines[i].time_ms; + if (t !== undefined && t !== null) + out.push({ index: i, time: t }); + } + return out; + } + + // --- local clock ------------------------------------------------------------------------ + // The shared transport samples position at ~4 Hz; the word timer interpolates between samples + // at 15 Hz so the active word fills smoothly, and resyncs to every real sample. + property real interpolatedPos: Playback.position + property real clockBase: 0 + property real clockAt: 0 + readonly property real posMs: root.interpolatedPos * 1000 - root.offsetMs + + readonly property int activeIndex: { + var currentMs = root.posMs; + var lines = root.timedLines; + var lo = 0, hi = lines.length - 1, answer = -1; + while (lo <= hi) { + var mid = (lo + hi) >> 1; + if (lines[mid].time <= currentMs) { + answer = lines[mid].index; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return answer; + } + + // --- auto-follow / manual scroll -------------------------------------------------------- + property bool manualScroll: false + property bool hasScrolled: false + + function fmtOffset(ms) { + if (!ms) + return "0.0s"; + return (ms > 0 ? "+" : "") + (ms / 1000).toFixed(1) + "s"; + } + + // --- fetch ------------------------------------------------------------------------------ + function maybeFetch(force) { + var now = Playback.now; + if (!root.visible || !now) { + if (!now) { + root.requestedId = ""; + root.lyrics = null; + root.loading = false; + } + return; + } + if (!force && now.videoId === root.requestedId) + return; + var id = now.videoId; + root.requestedId = id; + root.offsetMs = 0; + root.loading = true; + root.lyrics = null; + var q = Playback.queue; + var cur = (q && q.items) ? q.items[q.currentIndex] : null; + Daemon.call("get_lyrics", { + videoId: id, + title: now.title, + artists: now.artists, + album: (cur && cur.album) ? cur.album : undefined, + duration: PB.durationToSeconds(now.duration) + }).then((l) => { + if (root.requestedId !== id) + return; + root.lyrics = l; + root.loading = false; + root.hasScrolled = false; + root.manualScroll = false; + }).catch(() => { + if (root.requestedId !== id) + return; + root.lyrics = null; + root.loading = false; + }); + } + + onVisibleChanged: root.maybeFetch(false) + Component.onCompleted: root.maybeFetch(false) + + Connections { + target: Playback + function onNowChanged() { root.maybeFetch(false); } + // Resync the local clock to every authoritative transport sample. + function onPositionChanged() { + root.clockBase = Playback.position; + root.clockAt = Date.now(); + if (!wordTimer.running) + root.interpolatedPos = Playback.position; + } + function onPausedChanged() { + if (Playback.paused) + root.interpolatedPos = Playback.position; + } + } + + // The word clock: the only recurring work this view does, gated exactly per the plan. + Timer { + id: wordTimer + interval: 67 + repeat: true + running: root.visible && !Playback.paused && root.synced && root.hasWordTiming + onRunningChanged: { + root.clockBase = Playback.position; + root.clockAt = Date.now(); + } + onTriggered: root.interpolatedPos = root.clockBase + (Date.now() - root.clockAt) / 1000 + } + + // Centre the active line, gliding after the first landing (which jumps). + NumberAnimation { + id: scrollAnim + target: view + property: "contentY" + duration: Style.motion.swap + easing.type: Easing.OutCubic + } + function follow(i, animate) { + if (i < 0 || view.count === 0) + return; + var prev = view.contentY; + view.positionViewAtIndex(i, ListView.Center); + var goal = view.contentY; + view.contentY = prev; + if (animate) { + scrollAnim.stop(); + scrollAnim.from = prev; + scrollAnim.to = goal; + scrollAnim.start(); + } else { + scrollAnim.stop(); + view.contentY = goal; + } + } + onActiveIndexChanged: { + if (root.manualScroll || root.activeIndex < 0) + return; + root.follow(root.activeIndex, root.hasScrolled); + root.hasScrolled = true; + } + Timer { + id: manualTimer + interval: 5000 + onTriggered: { + root.manualScroll = false; + if (root.activeIndex >= 0) + root.follow(root.activeIndex, true); + } + } + function pauseFollow() { + root.manualScroll = true; + manualTimer.restart(); + } + function returnToCurrent() { + manualTimer.stop(); + root.manualScroll = false; + if (root.activeIndex >= 0) + root.follow(root.activeIndex, true); + } + function seekToLine(line) { + if (line.time_ms === undefined || line.time_ms === null) + return; + var secs = Math.max(0, (line.time_ms + root.offsetMs) / 1000); + Playback.position = secs; // jump the view along with the seek + Playback.seek(secs); + manualTimer.stop(); + root.manualScroll = false; + } + + // --- layout ----------------------------------------------------------------------------- + ColumnLayout { + anchors.fill: parent + anchors.margins: root.compact ? Style.sp(4) : 0 + spacing: 0 + + // Synced / plain lines ----------------------------------------------------------- + ListView { + id: view + Layout.fillWidth: true + Layout.fillHeight: true + visible: !root.loading && !root.instrumental && !!root.lyrics && !!root.lyrics.lines && root.lyrics.lines.length > 0 + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + // Breathing room so the active cue can sit in the reading zone rather than the edge. + header: Item { width: 1; height: view.height * 0.34 } + footer: Item { width: 1; height: view.height * 0.34 } + model: (root.lyrics && root.lyrics.lines) ? root.lyrics.lines : [] + + onMovementStarted: root.pauseFollow() + + delegate: Item { + id: lineWrap + required property var modelData + required property int index + width: view.width + implicitHeight: lineCol.implicitHeight + Style.sp(2) + + readonly property bool isActive: root.synced && lineWrap.index === root.activeIndex + readonly property bool isPast: root.synced && lineWrap.index < root.activeIndex + readonly property bool wordMode: lineWrap.isActive && !!(lineWrap.modelData.words && lineWrap.modelData.words.length) + readonly property int lineFs: root.compact + ? (lineWrap.isActive ? Style.fs.lg : Style.fs.md) + : (lineWrap.isActive ? Style.fs.xl : Style.fs.lg) + // The active line reads in ink and slightly larger, upcoming lines in inkMuted and + // past lines in inkFaint; opacity stays full so colour carries the state, and the + // viewport haze then fades every line by its distance from the reading centre. + readonly property color lineColor: (!root.synced || lineWrap.isActive) ? Tokens.ink + : lineWrap.isPast ? Tokens.inkFaint : Tokens.inkMuted + readonly property real baseOpacity: root.synced ? 1 : 0.85 + // The active word is the last one whose start has passed: earlier words are sung ink, + // it lights in the accent, later words wait muted. + readonly property int activeWord: { + if (!lineWrap.wordMode) + return -1; + var ws = lineWrap.modelData.words, p = root.posMs, idx = -1; + for (var i = 0; i < ws.length; i++) { + if (ws[i].start_ms <= p) + idx = i; + else + break; + } + return idx; + } + // A centred reading column, capped at 560 on the wide stage, snug in the mini. + readonly property real colW: root.compact + ? Math.max(1, lineWrap.width - Style.sp(4)) + : Math.min(lineWrap.width - Style.sp(8), Style.sp(140)) + + ColumnLayout { + id: lineCol + x: (lineWrap.width - lineCol.width) / 2 + width: lineWrap.colW + anchors.verticalCenter: parent.verticalCenter + spacing: Style.sp(0.5) + // Reading-centre haze: full at the middle line, falling to nothing at the edges. + opacity: { + var mid = lineWrap.y + lineWrap.height / 2 - view.contentY; + var d = view.height > 0 ? Math.min(1, Math.abs(mid - view.height / 2) / view.height) : 0; + return lineWrap.baseOpacity * (1 - Math.pow(d, 0.45)); + } + + // Plain line (no word timing, or a non-active line): ink, dimmed by the opacity. + Text { + Layout.fillWidth: true + visible: !lineWrap.wordMode + text: lineWrap.modelData.text ? lineWrap.modelData.text : "♪" + color: lineWrap.lineColor + font.family: Style.fontDisplay + font.pixelSize: lineWrap.lineFs + font.weight: Font.Medium + wrapMode: Text.WordWrap + lineHeight: 1.2 + } + + // Active line with word timing: the karaoke fill driven by root.posMs. + Flow { + Layout.fillWidth: true + visible: lineWrap.wordMode + spacing: Style.sp(1) + Repeater { + model: lineWrap.wordMode ? lineWrap.modelData.words : [] + delegate: Text { + id: word + required property int index + required property var modelData + text: word.modelData.text ? String(word.modelData.text).replace(/\s+$/, "") : "" + color: word.index === lineWrap.activeWord ? Style.accent + : word.index < lineWrap.activeWord ? Tokens.ink + : Qt.rgba(Tokens.inkMuted.r, Tokens.inkMuted.g, Tokens.inkMuted.b, 0.72) + font.family: Style.fontDisplay + font.pixelSize: lineWrap.lineFs + font.weight: Font.Medium + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + } + } + + // Translation, if the provider supplied one. + Text { + Layout.fillWidth: true + visible: !!lineWrap.modelData.translation + text: lineWrap.modelData.translation ? lineWrap.modelData.translation : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: root.compact ? Style.fs.xs : Style.fs.sm + font.italic: true + wrapMode: Text.WordWrap + } + } + + MouseArea { + anchors.fill: parent + enabled: root.synced + cursorShape: root.synced ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.seekToLine(lineWrap.modelData) + } + } + + // "Jump back to the current line" while auto-follow is paused. + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: Style.sp(2) + visible: root.manualScroll && root.synced && root.activeIndex >= 0 && !root.compact + z: 5 + implicitWidth: currentLabel.implicitWidth + Style.sp(4) + implicitHeight: Style.sp(7) + radius: height / 2 + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + Text { + id: currentLabel + anchors.centerIn: parent + text: "CURRENT LINE" + color: Tokens.inkDim + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.letterSpacing: 1.2 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.returnToCurrent() + } + } + } + + // Resolving state ---------------------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.loading + Item { Layout.fillHeight: true } + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Style.sp(120) + spacing: Style.sp(1) + Text { + text: "// RESOLVING LYRICS" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.letterSpacing: 1.2 + } + Text { + Layout.fillWidth: true + text: Playback.now && Playback.now.title ? Playback.now.title : "Current track" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: Playback.now && Playback.now.artists ? Playback.now.artists : "Waiting for metadata" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + Item { Layout.fillHeight: true } + } + + // Empty / instrumental state ----------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !root.loading && (root.instrumental || !root.lyrics || !root.lyrics.lines || root.lyrics.lines.length === 0) + Item { Layout.fillHeight: true } + // Compact (mini): a centred mono eyebrow + one muted line — never blank. + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Math.max(Style.sp(20), root.width - Style.sp(8)) + visible: root.compact + spacing: Style.sp(1) + Text { + Layout.alignment: Qt.AlignHCenter + text: root.instrumental ? "INSTRUMENTAL" : "NO LYRICS" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: root.instrumental ? "No vocal lines to follow." : "Nothing found for this track." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + + // Full stage: icon, headline, meta line and a retry affordance. + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Style.sp(120) + visible: !root.compact + spacing: Style.sp(1.5) + Icon { + Layout.alignment: Qt.AlignHCenter + name: "mic" + size: Style.fs.xl + color: Tokens.inkFaint + } + Text { + Layout.alignment: Qt.AlignHCenter + text: root.instrumental ? "Instrumental track." : "No lyrics found for this track." + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: root.instrumental + ? "There are no vocal lines to follow for this recording." + : ((Playback.now && Playback.now.title ? Playback.now.title : "Current track") + + " · " + (Playback.now && Playback.now.artists ? Playback.now.artists : "Unknown artist")) + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Rectangle { + Layout.alignment: Qt.AlignHCenter + visible: !root.instrumental + implicitWidth: retryRow.implicitWidth + Style.sp(4) + implicitHeight: Style.sp(8) + radius: Style.radius + color: retryHover.hovered ? Tokens.tint5 : "transparent" + border.width: 1 + border.color: Tokens.line + RowLayout { + id: retryRow + anchors.centerIn: parent + spacing: Style.sp(1.5) + Icon { name: "loading"; size: Style.fs.sm; color: Tokens.inkDim } + Text { + text: "Retry lookup" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + HoverHandler { id: retryHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.maybeFetch(true) + } + } + } + Item { Layout.fillHeight: true } + } + + // Footer: a single mono-micro attribution eyebrow + the timing offset (synced only, + // non-compact), set 16 px below the lyric column. + Hairline { Layout.fillWidth: true; Layout.topMargin: Style.sp(4); visible: lyricsFooter.visible } + RowLayout { + id: lyricsFooter + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(2) + Layout.bottomMargin: Style.sp(2) + spacing: Style.sp(2) + visible: !root.compact && !!root.lyrics && !root.loading + Text { + Layout.fillWidth: true + text: { + var s = (root.lyrics && root.lyrics.source) ? root.lyrics.source : ""; + if (!s) + return ""; + return (s.indexOf("Source:") === 0 ? s : ("Lyrics from " + s)).toUpperCase(); + } + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + RowLayout { + visible: root.synced + spacing: Style.sp(1) + Text { + text: "Timing " + root.fmtOffset(root.offsetMs) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + } + Repeater { + model: [{ label: "−0.5", d: -500 }, { label: "Reset", d: 0 }, { label: "+0.5", d: 500 }] + delegate: Rectangle { + id: offBtn + required property var modelData + implicitWidth: offLabel.implicitWidth + Style.sp(3) + implicitHeight: Style.sp(6) + radius: Style.radius + color: offHover.hovered ? Tokens.tint5 : "transparent" + Text { + id: offLabel + anchors.centerIn: parent + text: offBtn.modelData.label + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.xs + } + HoverHandler { id: offHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (offBtn.modelData.d === 0) + root.offsetMs = 0; + else + root.offsetMs = Math.max(-5000, Math.min(5000, root.offsetMs + offBtn.modelData.d)); + } + } + } + } + } + } + } +} diff --git a/client/chrome/NowPlaying.qml b/client/chrome/NowPlaying.qml new file mode 100644 index 0000000..d6265b8 --- /dev/null +++ b/client/chrome/NowPlaying.qml @@ -0,0 +1,278 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import Ryoku.Ui as RU +import "../" +import "../components" + +// The Now Playing stage (spec section 3): the playing surface at full size, artwork-first. It +// replaces the content area and the right panel when open. The cover fills the left column over a +// 0.6 Backdrop wash, lit by a static accent glow that re-renders only on track change; the lyrics +// stage fills the right column; a Spectrum ribbon runs along the foot while a track plays. It holds +// no state beyond the play/pause flash on the artwork — App owns the open state and the active tab +// through nowPlayingOpen / queueOpen / lyricsOpen, and this surface asks for changes through +// tabRequested / closeRequested so the player-bar toggles stay in lockstep. The Queue lives in +// App's right panel; the stage is the immersive lyrics view. +Item { + id: root + // The 0.6 backdrop blurs past its own bounds; clip so the wash never leaks over the title bar + // or the rail. + clip: true + + // App's coupling flags, one-way in; writes go back as signals so App owns the mutation. + property bool nowPlayingOpen: false + property bool queueOpen: false + property bool lyricsOpen: false + signal tabRequested(string tab) + signal closeRequested() + + readonly property bool open: root.nowPlayingOpen || root.queueOpen || root.lyricsOpen + + // The stage is present while open with a track; it fades in/out (opacity only) so collapsing + // reads as a soft dismissal — 200 ms enter, faster 150 ms exit, both OutCubic. + readonly property bool shown: root.open && !!Playback.now + visible: opacity > 0 + opacity: root.shown ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: root.shown ? 200 : 150; easing.type: Easing.OutCubic } + } + + // The current queue item, for the album meta the now-playing snapshot does not carry. + readonly property var nowItem: { + var q = Playback.queue; + if (!q || !q.items || !q.items.length) + return null; + var i = q.currentIndex >= 0 ? q.currentIndex : 0; + return q.items.length > i ? q.items[i] : null; + } + + // A SoundCloud track shows its plays / likes / genre in the meta line and its waveform under + // the title, the Orange look at full size. + readonly property bool scNow: !!Playback.now && String(Playback.now.videoId).startsWith("sc:") + + // The spectrum ribbon claims the analyser only while the stage is on screen. + onVisibleChanged: Spectrum.claim("stage", root.visible) + Component.onCompleted: Spectrum.claim("stage", root.visible) + + // --- artwork play/pause flash ----------------------------------------------------------- + property string flash: "" + Timer { id: flashTimer; interval: 240; onTriggered: root.flash = "" } + function toggle() { + root.flash = Playback.paused ? "play" : "pause"; + flashTimer.restart(); + Playback.togglePause(); + } + + // Opaque base + click swallow, so the routed page behind never takes a stray click. + Rectangle { + anchors.fill: parent + color: Tokens.paper + MouseArea { anchors.fill: parent } + } + + // The playing cover as the room's light, stronger here than on a page (spec 1). + Backdrop { + anchors.fill: parent + strength: 0.24 + } + + ColumnLayout { + id: stage + anchors.fill: parent + anchors.margins: Style.sp(12) + anchors.topMargin: Style.sp(8) + spacing: Style.sp(6) + + // ── header: eyebrow left, the Collapse pill right (in flow, so nothing sits under it) ── + RowLayout { + id: headRow + Layout.fillWidth: true + spacing: Style.sp(4) + Text { + Layout.fillWidth: true + text: (root.scNow ? "SOUNDCLOUD" : (Playback.provider === "spotify" ? "SPOTIFY" : "YOUTUBE MUSIC")) + " \u00b7 NOW PLAYING" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Rectangle { + id: collapsePill + implicitWidth: collapseRow.implicitWidth + Style.sp(6) + implicitHeight: Style.sp(9) + radius: height / 2 + color: collapseHover.hovered ? Tokens.tint5 : Tokens.paperLift + border.width: 1 + border.color: collapseHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + + RowLayout { + id: collapseRow + anchors.centerIn: parent + spacing: Style.sp(1.5) + Text { + text: "Collapse" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + } + Icon { name: "chevron-down"; size: Style.fs.md; color: Tokens.inkDim } + } + + HoverHandler { id: collapseHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.closeRequested() + } + } + } + + // ── body: art + track (left, top-aligned) | lyrics (right), tops on one line ────── + RowLayout { + id: body + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Style.sp(12) + + ColumnLayout { + id: artCol + Layout.fillHeight: true + Layout.fillWidth: false + readonly property int colW: Math.min(Style.sp(110), Math.round(root.width * 0.34)) + Layout.preferredWidth: colW + spacing: Style.sp(5) + // The cover is as wide as the column unless the height says otherwise: the title + // block (~3 lines) must always fit beneath it. The size comes from the root and + // the header's implicit height -- layout inputs. Reading this column's assigned + // width or body's assigned height fed layout outputs back into preferredWidth/ + // Height, re-polishing the child inside every updatePolish: the polish() loop + // that stuttered window resizes (#196). + readonly property int artPx: Math.max(Style.sp(40), + Math.min(colW, root.height - Style.sp(20) - headRow.implicitHeight + - Style.sp(14) - Style.sp(12) - Style.sp(30))) + + Artwork { + id: bigArt + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + Layout.preferredWidth: artCol.artPx + Layout.preferredHeight: artCol.artPx + px: artCol.artPx + cornerRadius: Style.radiusCard + url: (Playback.now && Playback.now.thumbnail) ? Playback.now.thumbnail : "" + placeholderIcon: "music" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggle() + } + + // Flash the action taken, over the cover, so the click visibly did something. + Rectangle { + anchors.centerIn: parent + visible: root.flash !== "" + width: Style.sp(14) + height: width + radius: width / 2 + color: Qt.rgba(0, 0, 0, 0.55) + Icon { + anchors.centerIn: parent + name: root.flash === "play" ? "play" : "pause" + size: Style.fs.xl + color: "#ffffff" + } + } + } + + // title xl Fraunces · artist sm · meta micro + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: artCol.artPx + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: (Playback.now && Playback.now.title) ? Playback.now.title : "" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.xl + maximumLineCount: 2 + wrapMode: Text.WordWrap + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: (Playback.now && Playback.now.artists) ? Playback.now.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: text !== "" + text: { + var parts = []; + if (root.scNow) { + var n = Playback.now; + var p = Style.fmtCount(n.plays); + if (p) parts.push(p + " PLAYS"); + var l = Style.fmtCount(n.likes); + if (l) parts.push(l + " LIKES"); + if (n.genre) parts.push(String(n.genre).toUpperCase()); + } else if (root.nowItem && root.nowItem.album) { + parts.push(String(root.nowItem.album).toUpperCase()); + } + if (Playback.duration > 0) + parts.push(Style.fmtTime(Playback.duration)); + return parts.join(" \u00b7 "); + } + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + } + + Item { Layout.fillHeight: true } + } + + LyricsPanel { + Layout.fillWidth: true + Layout.fillHeight: true + } + } + + // ── foot: ONE band. SoundCloud's own waveform is the seek bar; everything else gets the + // live spectrum ribbon. Never both. + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.sp(14) + + Waveform { + anchors.fill: parent + visible: root.scNow + samples: Playback.waveform || [] + } + RU.SpectrumField { + anchors.fill: parent + visible: !root.scNow + levels: Spectrum.levels + energy: Spectrum.energy + style: "wave" + ramp: [Style.accent, Tokens.ink] + boxX: 0 + boxY: 0 + boxW: 1 + boxH: 1 + grow: "center" + opacity: Spectrum.analysing ? 0.6 : 0.25 + Behavior on opacity { NumberAnimation { duration: Style.motion.swap } } + } + } + } +} diff --git a/client/chrome/PlayerBar.qml b/client/chrome/PlayerBar.qml new file mode 100644 index 0000000..7120e3a --- /dev/null +++ b/client/chrome/PlayerBar.qml @@ -0,0 +1,345 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/ids.js" as Ids + +// The persistent transport (spec section 3): three zones with the transport centred on the WINDOW. +// The left (now playing) and right (tools + volume) zones flow in a full-width RowLayout with a fixed +// 280 px each; the transport + seek is a separate column anchored to the bar's horizontal centre, so +// it sits at the window's midline whatever the side content does. It holds no truth: every control is +// a Playback method (a daemon call) and every readout is Playback state, save the two sanctioned +// optimisms — the held seek thumb (Playback.seekDrag) and the volume slider while dragged +// (Playback.volDrag). Queue / Lyrics select App's right panel; expand raises the Now Playing stage; +// the Sound dialog opens from the tools. The wave is gone: the seek is a hairline with an accent fill. +Rectangle { + id: root + + signal toggleQueue() + signal toggleLyrics() + signal toggleNowPlaying() + signal soundClicked() + + // App state, one-way in: the right panel's open flag and active tab, and the stage's open flag. + property bool panelOpen: false + property string panelTab: "queue" + property bool nowPlayingOpen: false + + // Volume level to return to when un-muting (mute is just volume 0). + property int preMute: 100 + + readonly property var now: Playback.now + readonly property bool live: !Playback.paused && !!Playback.now + readonly property bool hasYouTubeTrack: !!Playback.now + && !Ids.isLocalId(Playback.now.videoId) && !Ids.isRadioId(Playback.now.videoId) + readonly property bool isRadioNow: !!Playback.now && Ids.isRadioId(Playback.now.videoId) + readonly property bool scNow: !!Playback.now && String(Playback.now.videoId).startsWith("sc:") + readonly property bool autoplayTrack: { + var q = Playback.queue; + var cur = (q && q.items) ? q.items[q.currentIndex] : null; + return !!(cur && cur.autoplay && Playback.now && cur.video_id === Playback.now.videoId); + } + + // The fixed side zone width (280) and the outer margin, shared by the flow layout and the centre's + // width clamp so the transport never collides with the side content on a narrow window. + readonly property int zoneW: Style.sp(70) + readonly property int edge: Style.sp(6) + + implicitHeight: Style.playerBarH + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.9) + + function toggleLike() { + var n = Playback.now; + if (!n || !root.hasYouTubeTrack) + return; + var next = Playback.rating === "like" ? "indifferent" : "like"; + Playback.rating = next; + Daemon.call("rate", { videoId: n.videoId, rating: next }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not rate", "error")); + } + + function toggleMute() { + var muted = Playback.volume === 0; + if (!muted) + root.preMute = Playback.volume; + var v = muted ? (root.preMute || 100) : 0; + Playback.volume = v; + Playback.setVolume(v); + Daemon.call("set_setting", { key: "volume", value: String(v) }).catch(() => {}); + } + + function nudgeVolume(delta) { + var v = Math.max(0, Math.min(100, Playback.volume + delta)); + Playback.volume = v; + Playback.setVolume(v); + volSettle.restart(); + } + + // Persist the level once a run of wheel notches settles (one fsync per gesture, like the Svelte). + Timer { + id: volSettle + interval: 400 + onTriggered: Daemon.call("set_setting", { key: "volume", value: String(Playback.volume) }).catch(() => {}) + } + + Hairline { anchors.top: parent.top; width: parent.width; height: 1 } + + // ── side zones (left now-playing, right tools) flowing across the bar ─────────────────── + RowLayout { + anchors.fill: parent + anchors.leftMargin: root.edge + anchors.rightMargin: root.edge + spacing: Style.sp(4) + + // now playing (left, 280) + RowLayout { + Layout.preferredWidth: root.zoneW + Layout.fillWidth: false + spacing: Style.sp(3) + + Artwork { + url: root.now && root.now.thumbnail ? root.now.thumbnail : "" + px: Style.sp(12) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { + Layout.fillWidth: true + text: root.now && root.now.title ? root.now.title : "Nothing playing" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + visible: !!Playback.pendingVideoId + text: "RESOLVING" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.letterSpacing: 1 + } + Icon { + visible: root.autoplayTrack + name: "infinity" + size: Style.fs.sm + color: Tokens.inkMuted + } + } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + // The provider tell for the playing track: spotify glyph for spotify: ids, the + // YouTube Music glyph otherwise. + Icon { + visible: !!root.now + name: root.now ? (String(root.now.videoId).startsWith("sc:") ? "soundcloud" + : String(root.now.videoId).startsWith("spotify:") ? "spotify" : "youtube-music") : "youtube-music" + size: Style.sp(3.5) + color: Tokens.inkMuted + } + Text { + Layout.fillWidth: true + text: root.now && root.now.artists ? root.now.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + } + + IconButton { + visible: root.hasYouTubeTrack + icon: "heart" + iconSize: Style.fs.md + diameter: Style.sp(8) + active: Playback.rating === "like" + iconColor: Playback.rating === "like" ? Style.accent : Tokens.inkMuted + onClicked: root.toggleLike() + } + DownloadButton { + visible: !!root.now + diameter: Style.sp(8) + iconSize: Style.fs.md + track: root.now + } + } + + // the gap the centred transport floats over + Item { Layout.fillWidth: true; Layout.fillHeight: true } + + // tools + volume (right, 280) + RowLayout { + Layout.preferredWidth: root.zoneW + Layout.fillWidth: false + layoutDirection: Qt.RightToLeft + spacing: Style.sp(1) + + IconButton { + icon: "expand" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: root.nowPlayingOpen + onClicked: root.toggleNowPlaying() + } + IconButton { + icon: "queue" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: root.panelOpen && root.panelTab === "queue" + onClicked: root.toggleQueue() + } + IconButton { + icon: "mic" + iconSize: Style.fs.lg + diameter: Style.sp(8) + enabled: !root.isRadioNow + active: root.panelOpen && root.panelTab === "lyrics" + onClicked: root.toggleLyrics() + } + IconButton { + icon: "sound" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: root.soundClicked() + } + + Item { Layout.fillWidth: true } + + // volume group + RowLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(1) + layoutDirection: Qt.LeftToRight + + IconButton { + icon: Playback.volume === 0 ? "volume-mute" : "volume" + iconSize: Style.fs.md + diameter: Style.sp(8) + onClicked: root.toggleMute() + } + Slider { + id: volSlider + Layout.preferredWidth: Style.sp(24) + Layout.alignment: Qt.AlignVCenter + fillColor: Tokens.ink + from: 0 + to: 100 + value: Playback.volume + onPressedChanged: Playback.volDrag = pressed + onMoved: (v) => { + var iv = Math.round(v); + Playback.volume = iv; + Playback.setVolume(iv); + } + onCommitted: (v) => { + var iv = Math.round(v); + Playback.volume = iv; + Playback.setVolume(iv); + Daemon.call("set_setting", { key: "volume", value: String(iv) }).catch(() => {}); + Playback.volDrag = false; + } + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (e) => root.nudgeVolume(e.angleDelta.y > 0 ? 5 : -5) + } + } + } + } + } + + // ── transport + seek, anchored to the WINDOW's midline (max 480 wide) ─────────────────── + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: Math.min(Style.sp(120), root.width - 2 * (root.zoneW + root.edge + Style.sp(4))) + spacing: Style.sp(1) + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Style.sp(1) + IconButton { + icon: "shuffle" + iconSize: Style.fs.md + diameter: Style.sp(8) + active: !!(Playback.queue && Playback.queue.shuffle) + onClicked: Playback.toggleShuffle() + } + IconButton { + icon: "previous" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: Playback.prev() + } + IconButton { + icon: Playback.paused ? "play" : "pause" + iconSize: Style.fs.lg + diameter: Style.sp(10) + primary: true + onClicked: Playback.togglePause() + } + IconButton { + icon: "next" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: Playback.next() + } + IconButton { + icon: (Playback.queue && Playback.queue.repeat === "one") ? "repeat-one" : "repeat" + iconSize: Style.fs.md + diameter: Style.sp(8) + active: !!(Playback.queue && Playback.queue.repeat && Playback.queue.repeat !== "off") + onClicked: Playback.cycleRepeat() + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { + text: Style.fmtTime(Playback.shownPosition) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + Slider { + id: seekSlider + visible: !root.scNow + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + thickness: 2 + handleSize: Style.sp(2.5) + trackColor: Tokens.lineStrong + fillColor: root.live ? Style.accent : Tokens.ink + from: 0 + to: Playback.duration > 0 ? Playback.duration : 1 + value: Playback.shownPosition + onMoved: (v) => Playback.seekDrag = v + onCommitted: (v) => { Playback.seek(v); Playback.seekDrag = NaN; } + } + // A SoundCloud track swaps the hairline seek for the 28 px waveform (played in orange). + Waveform { + visible: root.scNow + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: Style.sp(7) + samples: Playback.waveform || [] + } + Text { + text: Style.fmtTime(Playback.duration) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + } + } +} diff --git a/client/chrome/QueuePanel.qml b/client/chrome/QueuePanel.qml new file mode 100644 index 0000000..d1df7a7 --- /dev/null +++ b/client/chrome/QueuePanel.qml @@ -0,0 +1,531 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The play queue, ported from ui/src/lib/components/QueueList.svelte and reshaped for the eye-candy +// pass. It shows the queue from the playing track forward: a highlighted NOW PLAYING card pinned at +// the top, then an UP NEXT list of 48 px rows (art, a fillWidth single-line title with the explicit +// badge after it, artist beneath, mono duration at the right, a drag grip revealed on hover), with +// "Stop after current" as a subtle outlined button at the foot. Every mutation is a daemon call: a +// grip drag commits move_in_queue (with the drop index corrected the same way the Svelte moveTarget +// does), the hover remove commits remove_from_queue, "Clear" commits clear_queued, and "Stop after +// current" commits set_stop_after_current. A search box filters the visible queue by title/artist/ +// album without touching order or playback, keeping each row's real backend index so play/remove +// still hit the right item. +Item { + id: root + + property string query: "" + + readonly property var q: Playback.queue + readonly property int currentIndex: (root.q && root.q.currentIndex >= 0) ? root.q.currentIndex : 0 + readonly property var items: (root.q && root.q.items) ? root.q.items : [] + readonly property var nowItem: (root.items.length > root.currentIndex) ? root.items[root.currentIndex] : null + + readonly property bool searching: root.query.trim().length > 0 + + // The row rhythm: 48 px art in a 56 px row, a 4 px gap between rows. + readonly property int rowH: Style.sp(14) + readonly property int rowStride: root.rowH + Style.sp(1) + + // Upcoming tracks (backend indices currentIndex+1 .. end), the draggable body of the panel. + readonly property var upcoming: { + var out = []; + for (var i = root.currentIndex + 1; i < root.items.length; i++) + out.push(root.items[i]); + return out; + } + readonly property bool hasQueued: { + for (var i = 0; i < root.upcoming.length; i++) { + var t = root.upcoming[i]; + if (t.queued || t.queued_end) + return true; + } + return false; + } + + // Visual search over the playing track and everything after it, each row carrying its real + // backend index so a hit still plays/removes the exact queue item. + readonly property var matches: { + var ql = root.query.trim().toLowerCase(); + var out = []; + if (!ql) + return out; + for (var i = root.currentIndex; i < root.items.length; i++) { + var t = root.items[i]; + if ((t.title && t.title.toLowerCase().indexOf(ql) >= 0) + || (t.artists && t.artists.toLowerCase().indexOf(ql) >= 0) + || (t.album && t.album.toLowerCase().indexOf(ql) >= 0)) + out.push({ item: t, i: i }); + } + return out; + } + readonly property var matchItems: root.matches.map((m) => m.item) + + // --- daemon actions --------------------------------------------------------------------- + function playBackend(i) { + Daemon.call("play_index", { index: i }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function removeBackend(i) { + Daemon.call("remove_from_queue", { index: i }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not remove", "error")); + } + // A grip dropped in front of `toArr` lands one slot earlier once the dragged row is out of the + // way when moving down — the moveTarget off-by-one, applied in backend space. + function reorder(fromArr, toArr) { + var offset = root.currentIndex + 1; + var bf = offset + fromArr; + var bDrop = offset + toArr; + var bt = bDrop > bf ? bDrop - 1 : bDrop; + if (bt === bf || bt < offset) + return; + Daemon.call("move_in_queue", { from: bf, to: bt }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not reorder", "error")); + } + function toggleStop() { + Daemon.call("set_stop_after_current", { enabled: !Playback.stopAfterCurrent }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not update", "error")); + } + function clearQueue() { + Daemon.call("clear_queued") + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not clear", "error")); + } + + // --- drag-reorder state ----------------------------------------------------------------- + property int dragFrom: -1 + property int dragTo: -1 + property real ghostY: 0 + property string ghostTitle: "" + function indexAt(y) { + var yy = upList.contentY + y; + return Math.max(0, Math.min(root.upcoming.length - 1, Math.floor(yy / root.rowStride))); + } + function beginDrag(from, y) { + root.dragFrom = from; + root.dragTo = from; + root.ghostY = y; + var it = root.upcoming[from]; + root.ghostTitle = it ? it.title : ""; + } + function updateDrag(y) { + root.ghostY = y; + root.dragTo = root.indexAt(y); + var edge = Style.sp(12); + if (y < edge) + upList.contentY = Math.max(0, upList.contentY - Style.sp(4)); + else if (y > upList.height - edge) + upList.contentY = Math.min(Math.max(0, upList.contentHeight - upList.height), upList.contentY + Style.sp(4)); + } + function endDrag() { + if (root.dragFrom >= 0 && root.dragTo >= 0 && root.dragFrom !== root.dragTo) + root.reorder(root.dragFrom, root.dragTo); + root.dragFrom = -1; + root.dragTo = -1; + } + + // A tracked mono micro eyebrow (NOW PLAYING / UP NEXT), left-aligned to the row content. + component Eyebrow: Text { + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + + // One queue row: 48 px art, a fillWidth single-line title with the explicit badge after it (the + // badge is aligned, never stealing the title's width), the artist beneath, the mono duration at + // the right (swapped for a remove control on hover), and a drag grip on hover for reorderable + // rows. `now` draws the highlighted paper-lift card for the playing track. + component QueueRow: Rectangle { + id: qr + property var song: null + property bool now: false + property bool reorderable: false + property bool removable: false + property int rowIndex: -1 + readonly property bool dragging: root.dragFrom === qr.rowIndex && qr.rowIndex >= 0 + signal play() + signal remove() + + readonly property string dur: (song && song.duration && /^[\d:]+$/.test(song.duration)) ? song.duration : "" + + implicitHeight: root.rowH + radius: Style.radius + color: qr.now ? Tokens.paperLift : (rowHover.hovered ? Tokens.tint5 : "transparent") + border.width: qr.now ? 1 : 0 + border.color: Tokens.lineSoft + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + + Artwork { + Layout.preferredWidth: Style.sp(12) + Layout.preferredHeight: Style.sp(12) + url: (qr.song && qr.song.thumbnail) ? qr.song.thumbnail : "" + px: Style.sp(12) + } + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: qr.song ? qr.song.title : "" + color: qr.now ? Style.accent : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Rectangle { + visible: !!(qr.song && qr.song.explicit) + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Style.sp(4) + Layout.preferredHeight: Style.sp(4) + radius: Style.sp(1) + color: "transparent" + border.width: 1 + border.color: Tokens.inkFaint + Text { anchors.centerIn: parent; text: "E"; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; font.weight: Font.DemiBold } + } + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: (qr.song && qr.song.artists) ? qr.song.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + // Duration, swapped for a remove control while hovering a removable row. + Item { + Layout.preferredWidth: Style.sp(9) + Layout.fillHeight: true + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: qr.dur !== "" && !(qr.removable && rowHover.hovered) + text: qr.dur + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + IconButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: qr.removable && rowHover.hovered + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(8) + tip: "Remove from queue" + onClicked: qr.remove() + } + } + + // Reorder grip: revealed on hover, drags the row to a new index (list coords). + Item { + id: gripArea + visible: qr.reorderable + Layout.preferredWidth: Style.sp(6) + Layout.fillHeight: true + opacity: (rowHover.hovered || qr.dragging) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Style.motion.snap } } + Column { + anchors.centerIn: parent + spacing: Style.sp(0.75) + Repeater { + model: 3 + delegate: Rectangle { + width: Style.sp(4) + height: Math.max(1, Style.sp(0.5)) + radius: height / 2 + color: gripDrag.active ? Tokens.ink : Tokens.inkFaint + } + } + } + DragHandler { + id: gripDrag + target: null + onActiveChanged: { + if (active) + root.beginDrag(qr.rowIndex, gripArea.mapToItem(upList, 0, centroid.position.y).y); + else + root.endDrag(); + } + onCentroidChanged: { + if (active) + root.updateDrag(gripArea.mapToItem(upList, 0, centroid.position.y).y); + } + } + } + } + + HoverHandler { id: rowHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: qr.play() } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Search box -------------------------------------------------------------------- + Item { + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(3) + Layout.bottomMargin: Style.sp(1) + implicitHeight: Style.ctlH + Rectangle { + anchors.fill: parent + radius: Style.radius + color: Tokens.tint5 + border.width: 1 + border.color: queueFilter.activeFocus ? Tokens.line : Tokens.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: queueFilter + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: root.query + onTextChanged: root.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: queueFilter.text.length === 0 + text: "Search queue" + color: Tokens.inkFaint + font: queueFilter.font + } + } + } + } + } + + // Empty state ------------------------------------------------------------------- + Text { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !root.nowItem + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: "The queue is empty." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + // Search results ---------------------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !!root.nowItem && root.searching + spacing: 0 + Eyebrow { + Layout.topMargin: Style.sp(1) + Layout.bottomMargin: Style.sp(1) + text: root.matches.length + (root.matches.length === 1 ? " MATCH" : " MATCHES") + } + ListView { + id: searchList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + spacing: Style.sp(1) + model: root.matchItems + delegate: QueueRow { + id: sRow + required property var modelData + required property int index + width: searchList.width + song: sRow.modelData + removable: true + onPlay: root.playBackend(root.matches[sRow.index].i) + onRemove: root.removeBackend(root.matches[sRow.index].i) + } + } + Text { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + visible: root.matches.length === 0 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: "No queued songs match “" + root.query.trim() + "”." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + + // Now playing + Up next --------------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !!root.nowItem && !root.searching + spacing: 0 + + Eyebrow { + Layout.topMargin: Style.sp(1) + Layout.bottomMargin: Style.sp(1) + text: "NOW PLAYING" + } + QueueRow { + Layout.fillWidth: true + Layout.leftMargin: Style.sp(2) + Layout.rightMargin: Style.sp(2) + now: true + song: root.nowItem + onPlay: root.playBackend(root.currentIndex) + } + + Eyebrow { + Layout.topMargin: Style.sp(3) + Layout.bottomMargin: Style.sp(1) + text: "UP NEXT" + } + + // The up-next list, with the reorder drop marker and the floating ghost row. + Item { + id: upWrap + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + id: upList + anchors.fill: parent + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + spacing: Style.sp(1) + model: root.upcoming + // A drag in progress must not also flick the list. + interactive: root.dragFrom < 0 + + delegate: Item { + id: wrap + required property var modelData + required property int index + width: upList.width + implicitHeight: root.rowH + + Rectangle { + visible: root.dragFrom >= 0 && root.dragTo === wrap.index + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 2 + color: Style.accent + } + QueueRow { + anchors.left: parent.left + anchors.right: parent.right + song: wrap.modelData + rowIndex: wrap.index + reorderable: true + removable: true + opacity: root.dragFrom === wrap.index ? 0.35 : 1 + onPlay: root.playBackend(root.currentIndex + 1 + wrap.index) + onRemove: root.removeBackend(root.currentIndex + 1 + wrap.index) + } + } + } + + Text { + anchors.centerIn: parent + visible: root.upcoming.length === 0 + text: "Nothing up next." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + + // The floating row that follows the grip during a reorder. + Rectangle { + visible: root.dragFrom >= 0 + width: upWrap.width - Style.sp(6) + x: Style.sp(3) + y: Math.max(0, Math.min(upWrap.height - height, root.ghostY - height / 2)) + implicitHeight: root.rowH + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + opacity: 0.95 + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + text: root.ghostTitle + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + + // Foot: Stop after current (subtle outlined), and Clear when there is a queued tail. + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(2) + Layout.bottomMargin: Style.sp(3) + spacing: Style.sp(2) + Hairline { Layout.fillWidth: true; soft: true } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Btn { + Layout.fillWidth: true + text: Playback.stopAfterCurrent ? "Stopping after this" : "Stop after current" + icon: Playback.stopAfterCurrent ? "check-circle" : "" + onClicked: root.toggleStop() + } + Btn { + visible: root.hasQueued + text: "Clear" + onClicked: root.clearQueue() + } + } + } + } + } +} diff --git a/client/chrome/RightPanel.qml b/client/chrome/RightPanel.qml new file mode 100644 index 0000000..4aad452 --- /dev/null +++ b/client/chrome/RightPanel.qml @@ -0,0 +1,152 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The persistent right column (Queue | Lyrics), the everyday companion to the routed page. Unlike the +// Now Playing stage (which takes the whole content), this is a docked 340 px panel App keeps open on a +// wide window. It owns no truth: the active tab is App's `tab`, written back through `tabRequested`, +// and the close chevron raises `closeRequested` so App clears `panelOpen`. Both panels stay +// instantiated across a tab switch and gate their timers on `visible`, so the lyrics keep their scroll +// and the queue its position, and neither spends CPU while the panel is closed. The column slides: its +// layout slot (implicitWidth) animates 0<->panelW while a fixed-width glass body anchored to the left +// is revealed/hidden by the clip, so the queue never reflows mid-slide. +Item { + id: root + + property bool open: true + property string tab: "queue" + signal tabRequested(string tab) + signal closeRequested() + + implicitWidth: root.open ? Style.panelW : 0 + clip: true + Behavior on implicitWidth { NumberAnimation { duration: Style.motion.slow; easing.type: Easing.OutCubic } } + + // The glass body: full panel width, anchored to the left edge so a shrinking slot slides it off to + // the right. Tokens.paper at 0.9 alpha reads as frosted glass over the content bloom beside it. + Rectangle { + id: glass + width: Style.panelW + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.9) + + Hairline { anchors.left: parent.left; width: 1; height: parent.height } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 1 + spacing: 0 + + // --- header: segmented Queue | Lyrics + close chevron ------------------------------- + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.titleBarH + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + + Rectangle { + id: seg + Layout.fillWidth: true + Layout.preferredHeight: Style.sp(8) + Layout.alignment: Qt.AlignVCenter + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + + readonly property real pad: Style.sp(0.75) + readonly property real half: (width - seg.pad * 2) / 2 + + // The sliding indicator, travelling under the labels on the selector timing. + Rectangle { + id: indicator + y: seg.pad + x: seg.pad + (root.tab === "lyrics" ? seg.half : 0) + width: seg.half + height: parent.height - seg.pad * 2 + radius: Style.radius - 1 + color: Tokens.bone + Behavior on x { NumberAnimation { duration: Style.motion.move; easing.type: Easing.OutCubic } } + } + + RowLayout { + anchors.fill: parent + anchors.margins: seg.pad + spacing: 0 + Repeater { + model: [ + { key: "queue", label: "Queue", icon: "queue" }, + { key: "lyrics", label: "Lyrics", icon: "mic" } + ] + delegate: Item { + id: tabBtn + required property var modelData + readonly property bool selected: root.tab === tabBtn.modelData.key + Layout.fillWidth: true + Layout.fillHeight: true + RowLayout { + anchors.centerIn: parent + spacing: Style.sp(1.5) + Icon { + name: tabBtn.modelData.icon + size: Style.fs.sm + color: tabBtn.selected ? Tokens.inkOnBone : Tokens.inkMuted + } + Text { + text: tabBtn.modelData.label + color: tabBtn.selected ? Tokens.inkOnBone : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.tabRequested(tabBtn.modelData.key) + } + } + } + } + } + + IconButton { + icon: "chevron-right" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: root.closeRequested() + } + } + + Hairline { anchors.bottom: parent.bottom; width: parent.width; height: 1 } + } + + // --- body: both panels alive, shown by tab ------------------------------------------ + Item { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + QueuePanel { + anchors.fill: parent + visible: root.open && root.tab === "queue" + } + LyricsPanel { + anchors.fill: parent + anchors.margins: Style.sp(4) + visible: root.open && root.tab === "lyrics" + compact: false + } + } + } + } +} diff --git a/client/chrome/SearchSuggest.qml b/client/chrome/SearchSuggest.qml new file mode 100644 index 0000000..f2fec6c --- /dev/null +++ b/client/chrome/SearchSuggest.qml @@ -0,0 +1,245 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/search.js" as Search +import "../lib/browse.js" as Browse + +// The search field with a live typeahead, ported from SearchSuggest.svelte. The input feeds a +// 150 ms-debounced search_all whose result is previewMixed into a small ranked set; Up/Down move the +// selection, Enter takes the highlighted row (or submits the query), Escape closes. Picking a song +// plays it, anything else routes to its page — the same openItem every browse surface uses. +Item { + id: root + + property string value: "" + property string placeholder: "Search songs, albums, artists, playlists…" + property var items: [] + property bool loading: false + property int active: -1 + property bool panelOpen: false + property string loadedFor: "" + + signal picked() + signal submitted() + + implicitHeight: field.height + + function openItem(item) { + if (item.kind === "song") + Playback.play(Browse.asSong(item)); + else + Router.push(item.kind, { id: item.id, title: item.title }); + } + function choose(item) { + root.panelOpen = false; + root.openItem(item); + root.picked(); + } + function forceFocus() { input.forceActiveFocus(); } + function runQuery() { + var q = root.value.trim().replace(/\s+/g, " "); + if (q.length < 2) { + root.items = []; + root.panelOpen = false; + return; + } + root.loading = true; + root.panelOpen = true; + var reqFor = q; + Daemon.call("search_all", { query: q }) + .then((res) => { + if (root.value.trim().replace(/\s+/g, " ") !== reqFor) + return; + root.items = Search.previewMix(res, 12); + root.active = root.items.length ? 0 : -1; + root.loadedFor = reqFor; + root.loading = false; + }) + .catch(() => { root.loading = false; }); + } + + Timer { id: debounce; interval: 150; onTriggered: root.runQuery() } + onValueChanged: { + // A programmatic value (a ?q= arrival that binds `value`) must not pop the dropdown; only a + // user typing into the focused field does. + if (!input.activeFocus) { debounce.stop(); return; } + if (root.value.trim().length < 2) { debounce.stop(); root.items = []; root.panelOpen = false; return; } + root.loading = true; + root.panelOpen = true; + debounce.restart(); + } + + // the field + Rectangle { + id: field + width: parent.width + implicitHeight: Style.sp(11) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: input.activeFocus ? Tokens.lineStrong : Tokens.line + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.md; color: Tokens.inkMuted } + TextInput { + id: input + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.value + onTextChanged: root.value = text + onAccepted: { + if (root.panelOpen && root.active >= 0 && root.active < root.items.length) { + root.choose(root.items[root.active]); + } else { + root.panelOpen = false; + root.submitted(); + } + } + Keys.onPressed: (e) => { + if (e.key === Qt.Key_Down) { if (root.items.length) root.active = (root.active + 1) % root.items.length; e.accepted = true; } + else if (e.key === Qt.Key_Up) { if (root.items.length) root.active = (root.active - 1 + root.items.length) % root.items.length; e.accepted = true; } + else if (e.key === Qt.Key_Escape) { root.panelOpen = false; e.accepted = true; } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text.length === 0 + text: root.placeholder + color: Tokens.inkFaint + font: input.font + } + } + IconButton { + visible: root.value.length > 0 + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(8) + onClicked: { root.value = ""; root.items = []; root.panelOpen = false; } + } + } + } + + // typeahead panel: paper over the backdrop, a hairline frame, a tracked micro section label and + // ranked rows (top hit slightly taller, a per-kind tracked micro tag, hairlines between rows). + Rectangle { + id: panel + visible: root.panelOpen && (root.items.length > 0 || root.loading) + anchors.top: field.bottom + anchors.topMargin: Style.sp(1) + anchors.left: field.left + width: field.width + implicitHeight: Math.min(sugg.contentHeight + Style.sp(2), Style.sp(110)) + height: implicitHeight + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + z: 60 + + ListView { + id: sugg + anchors.fill: parent + anchors.margins: Style.sp(1) + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: root.items + + header: Item { + width: sugg.width + implicitHeight: Style.sp(8) + Text { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.leftMargin: Style.sp(2) + anchors.bottomMargin: Style.sp(1.5) + text: "SUGGESTIONS" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + + delegate: Rectangle { + id: sRow + required property var modelData + required property int index + width: sugg.width + implicitHeight: sRow.index === 0 ? Style.sp(14) : Style.sp(12) + color: (root.active === sRow.index) ? Tokens.tint10 : sHover.hovered ? Tokens.tint5 : "transparent" + + // hairline between rows + Rectangle { + visible: sRow.index < root.items.length - 1 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + height: 1 + color: Tokens.lineSoft + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Artwork { + url: sRow.modelData.thumbnail ? sRow.modelData.thumbnail : "" + px: sRow.index === 0 ? Style.sp(11) : Style.sp(9) + round: sRow.modelData.kind === "artist" + placeholderIcon: sRow.modelData.kind === "artist" ? "user" : sRow.modelData.kind === "album" ? "cd" : sRow.modelData.kind === "playlist" ? "playlist" : "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: sRow.modelData.title + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: sRow.index === 0 ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: sRow.modelData.subtitle ? sRow.modelData.subtitle : sRow.modelData.kind + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + Text { + text: sRow.modelData.kind.toUpperCase() + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + HoverHandler { id: sHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onEntered: root.active = sRow.index + hoverEnabled: true + onClicked: root.choose(sRow.modelData) + } + } + } + } +} diff --git a/client/chrome/Sidebar.qml b/client/chrome/Sidebar.qml new file mode 100644 index 0000000..4cbd147 --- /dev/null +++ b/client/chrome/Sidebar.qml @@ -0,0 +1,492 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui as RU +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The navigation rail (spec section 3, eye-candy pass): a bordered brand card (力 tile + RYOTUNES / +// RYOKU // MUSIC eyebrow + a collapse button), the three route groups (Discover / Collection / +// System) as tracked mono micro eyebrows over hairlines, 40 px nav rows with an icon and a body +// label whose active state is a paper-lift fill plus a 2 px accent left border (hover is a faint +// tint), the Library group expanding to its five tabs while you are in it, a Playlists group with a +// full-width outlined "New playlist" button over the user's library playlists (from get_library, +// merged with any pinned playlists), and a RYOKU // MUSIC ··· LIVE register at the foot. The active +// route is read from Router.current; a click routes there. `open` collapses the rail: the layout +// slot animates to zero while a fixed-width glass body anchored to the right slides off to the left, +// so the content never reflows mid-slide. The collapse button raises `collapseRequested` (App owns +// the sidebarOpen truth), the same door the title bar's rail toggle opens. +Rectangle { + id: root + + property bool open: true + signal collapseRequested() + + readonly property string activePage: Router.current ? Router.current.page : "home" + readonly property string activeTab: (Router.current && Router.current.params && Router.current.params.tab) + ? Router.current.params.tab : "songs" + + // The user's library playlists (get_library, kind "playlist" — includes the smart On Repeat / + // Recently Played cards and device playlists), merged with any pinned playlists from the shared + // personal store, deduped by id. Reloaded on (re)connect and after a create. + property var libPlaylists: [] + readonly property var playlists: { + var seen = ({}), out = []; + for (var i = 0; i < root.libPlaylists.length; i++) { + var p = root.libPlaylists[i]; + if (p && p.id && !seen[p.id]) { seen[p.id] = true; out.push(p); } + } + var picks = Personal.picks; + for (var j = 0; j < picks.length; j++) { + var q = picks[j]; + if (q && q.kind === "playlist" && q.id && !seen[q.id]) { seen[q.id] = true; out.push(q); } + } + return out; + } + function loadPlaylists() { + Daemon.call("get_library") + .then((r) => { root.libPlaylists = (r || []).filter((i) => i && i.kind === "playlist"); }) + .catch(() => { root.libPlaylists = []; }); + } + // create_playlist exists in crates/ryotunesd/src/methods.rs (returns the new id); make one and + // open it so the user can name and fill it. No dialog lives in the rail. + function newPlaylist() { + Daemon.call("create_playlist", { title: "New playlist" }) + .then((id) => { + root.loadPlaylists(); + Playback.toast("Playlist created", "success"); + if (id) Router.push("playlist", { id: id, title: "New playlist" }); + }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not create", "error")); + } + Component.onCompleted: if (Daemon.connected) root.loadPlaylists(); + Connections { + target: Daemon + function onSnapshot(snap): void { root.loadPlaylists(); } + // A device-playlist mutation (a track added or removed, the cover set or reset) can change a + // playlist's auto cover; re-read the rail so its tiles follow. + function onEvent(name: string, data: var): void { + if (name === "library-changed") + root.loadPlaylists(); + } + } + + color: "transparent" + implicitWidth: root.open ? Style.sidebarW : 0 + clip: true + Behavior on implicitWidth { NumberAnimation { duration: Style.motion.slow; easing.type: Easing.OutCubic } } + + // A top-level nav row: 40 px, icon + body label + optional kana seal. Active = paper-lift fill + + // a 2 px accent left border; hover = a faint tint. + component NavRow: Rectangle { + id: nr + property string label: "" + property string icon: "" + property string kana: "" + property int badge: 0 + property bool current: false + signal activated() + activeFocusOnTab: enabled && visible + Accessible.role: Accessible.Button + Accessible.name: nr.label + Accessible.onPressAction: nr.activated() + Keys.onReturnPressed: nr.activated() + Keys.onSpacePressed: nr.activated() + border.width: activeFocus ? 2 : 0 + border.color: Tokens.ink + + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: nr.current ? Tokens.paperLift : navHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + + // The 2 px accent left border of the active row (state, per the eye-candy brief). + Rectangle { + visible: nr.current + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Style.sp(0.5) + height: parent.height - Style.sp(4) + radius: width / 2 + color: Style.accent + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(3) + Icon { + name: nr.icon + size: Style.fs.lg + color: nr.current ? Tokens.ink : Tokens.inkMuted + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: nr.label + color: nr.current ? Tokens.ink : Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: nr.current ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + Rectangle { + visible: nr.badge > 0 + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.sp(4.5) + implicitWidth: Math.max(Style.sp(4.5), badgeText.implicitWidth + Style.sp(2)) + radius: height / 2 + color: Style.accentSoft + Text { + id: badgeText + anchors.centerIn: parent + text: nr.badge > 99 ? "99+" : String(nr.badge) + color: Style.accent + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.weight: Font.DemiBold + } + } + Text { + visible: Style.decorRich && nr.kana !== "" + text: nr.kana + color: nr.current ? Tokens.inkMuted : Tokens.inkFaint + opacity: nr.current ? 0.9 : 0.6 + font.family: Tokens.jp + font.pixelSize: Style.fs.sm + } + } + HoverHandler { id: navHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: nr.activated() + } + } + + // A section register: number, tracked mono micro label, a hairline to the right and (rich) seal. + component SectionLabel: RowLayout { + id: sl + property string num: "" + property string label: "" + property string seal: "" + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(3) + Layout.bottomMargin: Style.sp(1) + spacing: Style.sp(2) + Text { + text: sl.num + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: sl.label + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Hairline { Layout.fillWidth: true; soft: true } + Text { + visible: Style.decorRich && sl.seal !== "" + text: sl.seal + color: Tokens.inkFaint + font.family: Tokens.jp + font.pixelSize: Style.fs.sm + } + } + + // An indented Library tab row (32 px), shown only while the Library is the active route. + component SubRow: Rectangle { + id: sr + property string label: "" + property string tab: "" + property bool current: false + signal activated() + + Layout.fillWidth: true + implicitHeight: Style.sp(8) + radius: Style.radius + color: subHover.hovered ? Tokens.tint5 : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(7) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(2) + Rectangle { + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Style.sp(1) + Layout.preferredHeight: sr.current ? Style.sp(3.5) : Style.sp(1) + radius: Style.sp(0.5) + color: sr.current ? Tokens.ink : Tokens.lineStrong + Behavior on Layout.preferredHeight { NumberAnimation { duration: Style.motion.snap } } + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: sr.label + color: sr.current ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: sr.current ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + } + HoverHandler { id: subHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: sr.activated() + } + } + + // A playlist row: a 40 px art tile, the title and its "N songs" sub, over a faint hover tint. + component PlaylistRow: Rectangle { + id: plr + property var item: null + signal activated() + + Layout.fillWidth: true + implicitHeight: Style.sp(12) + radius: Style.radius + color: plHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Artwork { + url: (plr.item && plr.item.thumbnail) ? plr.item.thumbnail : "" + px: Style.sp(10) + placeholderIcon: "playlist" + } + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 0 + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: plr.item ? plr.item.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: (plr.item && plr.item.subtitle) ? plr.item.subtitle : "Playlist" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + } + HoverHandler { id: plHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: plr.activated() + } + } + + // The frosted glass body, full width, anchored right so a shrinking slot slides it off the left. + Rectangle { + id: glass + width: Style.sidebarW + // The registration sheet behind the rail, the way the Hub's NavRail sits on one: the print + // texture rides the chrome, never the content. + RU.Reg { anchors.fill: parent; visible: Style.decorRich; opacity: 0.7 } + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.9) + + Hairline { anchors.right: parent.right; width: 1; height: parent.height } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.sp(2) + spacing: Style.sp(1) + + // --- brand card ----------------------------------------------------------------- + Rectangle { + Layout.fillWidth: true + Layout.bottomMargin: Style.sp(2) + implicitHeight: Style.sp(14) + radius: Style.radiusCard + color: "transparent" + border.width: 1 + border.color: Tokens.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(1) + spacing: Style.sp(2) + + Rectangle { + Layout.preferredWidth: Style.sp(10) + Layout.preferredHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineSoft + Text { + anchors.centerIn: parent + text: "力" + color: Tokens.ink + font.family: Tokens.jp + font.pixelSize: Style.fs.xl + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 1 + Text { + Layout.fillWidth: true + text: "RYOTUNES" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.DemiBold + font.letterSpacing: 2 + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: "RYOKU // MUSIC" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + } + IconButton { + icon: "rail-collapse" + iconSize: Style.fs.lg + diameter: Style.sp(8) + outlined: true + tip: "Collapse" + onClicked: root.collapseRequested() + } + } + } + + // --- scrolling nav body --------------------------------------------------------- + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: nav.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: nav + width: parent.width + spacing: Style.sp(1) + + // DISCOVER + SectionLabel { num: "01"; label: "DISCOVER"; seal: "聴" } + NavRow { label: "Home"; icon: "home"; kana: "聴"; current: root.activePage === "home"; onActivated: if (root.activePage !== "home") Router.push("home") } + NavRow { label: "Search"; icon: "search"; kana: "探"; current: root.activePage === "search"; onActivated: if (root.activePage !== "search") Router.push("search") } + NavRow { label: "Radio"; icon: "radio"; kana: "波"; current: root.activePage === "radio"; onActivated: if (root.activePage !== "radio") Router.push("radio") } + + // COLLECTION + SectionLabel { num: "02"; label: "COLLECTION"; seal: "蔵" } + NavRow { label: "Library"; icon: "library"; kana: "蔵"; current: root.activePage === "library"; onActivated: if (root.activePage !== "library") Router.push("library", { tab: "songs" }) } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + visible: root.activePage === "library" + SubRow { label: "Songs"; tab: "songs"; current: root.activeTab === "songs"; onActivated: Router.replace("library", { tab: "songs" }) } + SubRow { label: "Albums"; tab: "albums"; current: root.activeTab === "albums"; onActivated: Router.replace("library", { tab: "albums" }) } + SubRow { label: "Artists"; tab: "artists"; current: root.activeTab === "artists"; onActivated: Router.replace("library", { tab: "artists" }) } + SubRow { label: "Playlists"; tab: "playlists"; current: root.activeTab === "playlists"; onActivated: Router.replace("library", { tab: "playlists" }) } + SubRow { label: "Local"; tab: "local"; current: root.activeTab === "local"; onActivated: Router.replace("library", { tab: "local" }) } + } + NavRow { + label: "Downloads"; icon: "download"; kana: "保" + badge: Downloads.activeCount + Downloads.queuedCount + current: root.activePage === "downloads" + onActivated: if (root.activePage !== "downloads") Router.push("downloads") + } + + // SYSTEM + SectionLabel { num: "03"; label: "SYSTEM"; seal: "設" } + NavRow { label: "Settings"; icon: "settings"; kana: "設"; current: root.activePage === "settings"; onActivated: if (root.activePage !== "settings") Router.push("settings") } + + // PLAYLISTS + SectionLabel { num: "04"; label: "PLAYLISTS"; seal: "列" } + Btn { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + Layout.bottomMargin: Style.sp(1) + text: "New playlist" + icon: "add" + onClicked: root.newPlaylist() + } + Repeater { + model: root.playlists + delegate: PlaylistRow { + id: plRow + required property var modelData + item: plRow.modelData + onActivated: { + if (!plRow.modelData) + return; + Router.push("playlist", { id: plRow.modelData.id, title: plRow.modelData.title }); + } + } + } + } + } + + // --- LIVE register -------------------------------------------------------------- + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Text { + text: "RYOKU // MUSIC" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Hairline { Layout.fillWidth: true; soft: true } + Rectangle { + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Style.sp(2) + Layout.preferredHeight: Style.sp(2) + radius: Style.sp(1) + color: Style.ambient ? Style.accent : Tokens.inkFaint + } + Text { + text: "LIVE" + color: Style.ambient ? Tokens.inkDim : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + } +} diff --git a/client/chrome/SoundDialog.qml b/client/chrome/SoundDialog.qml new file mode 100644 index 0000000..0dc4fca --- /dev/null +++ b/client/chrome/SoundDialog.qml @@ -0,0 +1,272 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The Sound dialog (spec section 7): a Ryotunes feature ordinary players do not have. A centred +// modal over a snapshot-blurred freeze of the frame behind it (spec 9 — the page is captured once +// with our own visuals hidden, blurred, then a scrim, so no live subtree is ever blurred). Five +// sliders drive the daemon's audio-fx chain (tempo, pitch, reverb, bass, width) with a row of +// presets; every change is applied debounced 80 ms and the daemon echoes the authoritative values +// back through Playback.audioFx. The App hosts this in a Loader { active: app.soundOpen } and wires +// onCloseRequested; it needs nothing else from us. +Item { + id: root + + anchors.fill: parent + focus: true + signal closeRequested() + + // The working copy the sliders edit, seeded from the daemon mirror when the dialog opens. Kept + // local (not a live binding to Playback.audioFx) so a drag is not fought by the echo it causes. + property var fx: ({ speed: 1, semitones: 0, reverb: 0, bass: 0, width: 0 }) + // Snapshot state. `snapReady` gates the blur (the frozen frame is drawn once, then blurred); + // `shown` reveals the card and scrim. Both flip when the capture completes, so the snapshot is + // the frame alone — but a fallback timer flips `shown` regardless, so a build where the capture + // never completes still shows a usable dialog (over the unblurred page) instead of nothing. + property bool snapReady: false + property bool shown: false + + readonly property var presets: [ + { name: "Flat", fx: { speed: 1, semitones: 0, reverb: 0, bass: 0, width: 0 } }, + { name: "Slowed + Reverb", fx: { speed: 0.85, semitones: 0, reverb: 0.6, bass: 0, width: 0 } }, + { name: "Nightcore", fx: { speed: 1.25, semitones: 2, reverb: 0, bass: 0, width: 0 } }, + { name: "Bass Boost", fx: { speed: 1, semitones: 0, reverb: 0, bass: 8, width: 0 } }, + { name: "Wide", fx: { speed: 1, semitones: 0, reverb: 0, bass: 0, width: 0.8 } } + ] + + Component.onCompleted: { + var a = Playback.audioFx; + root.fx = { speed: a.speed, semitones: a.semitones, reverb: a.reverb, bass: a.bass, width: a.width }; + // Snapshot the frame behind us. Capture the frame-level item (the child of the window's + // content root), not the root itself — a plain Item renders to a texture, the window root + // does not — walking up until the next parent would be that root, whatever the nesting. + var p = root.parent; + while (p && p.parent && p.parent.parent) + p = p.parent; + frozen.sourceItem = p; + frozen.scheduleUpdate(); + root.forceActiveFocus(); + reveal.start(); + } + Keys.onEscapePressed: (e) => { root.closeRequested(); e.accepted = true; } + + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + // Reassign fx as a whole so the slider bindings (and preset-active state) refresh, then apply + // after 80 ms of quiet — a drag coalesces into one daemon call instead of one per frame. + function patch(p) { root.fx = Object.assign({}, root.fx, p); apply.restart(); } + function setSpeed(v) { root.patch({ speed: root.clamp(Math.round(v / 0.05) * 0.05, 0.25, 2.0) }); } + function setPitch(v) { root.patch({ semitones: root.clamp(Math.round(v), -12, 12) }); } + function setReverb(v) { root.patch({ reverb: root.clamp(Math.round(v * 100) / 100, 0, 1) }); } + function setBass(v) { root.patch({ bass: root.clamp(Math.round(v), -6, 12) }); } + function setWidth(v) { root.patch({ width: root.clamp(Math.round(v * 100) / 100, 0, 1) }); } + function applyPreset(p) { root.fx = { speed: p.speed, semitones: p.semitones, reverb: p.reverb, bass: p.bass, width: p.width }; apply.restart(); } + function presetActive(p) { + return root.fx.speed === p.speed && root.fx.semitones === p.semitones + && root.fx.reverb === p.reverb && root.fx.bass === p.bass && root.fx.width === p.width; + } + + Timer { + id: apply + interval: 80 + onTriggered: Playback.setAudioFx(root.fx).catch((e) => Playback.toast((e && e.message) ? e.message : "Sound settings failed", "error")) + } + // The capture normally reveals the card; this is the floor so it appears even if the frame's + // one-shot render never signals completion. + Timer { id: reveal; interval: 140; onTriggered: root.shown = true } + + // ── the spec-9 snapshot blur ───────────────────────────────────────────────────────────── + ShaderEffectSource { + id: frozen + anchors.fill: parent + live: false + recursive: false + visible: false + onScheduledUpdateCompleted: { root.snapReady = true; root.shown = true; } + } + MultiEffect { + anchors.fill: parent + source: frozen + visible: Style.blurEnabled && root.snapReady + blurEnabled: true + blur: 1.0 + blurMax: 32 + } + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: root.shown ? 0.5 : 0 + Behavior on opacity { NumberAnimation { duration: Style.motion.swap } } + } + MouseArea { + anchors.fill: parent + enabled: root.shown + onClicked: root.closeRequested() + } + + // ── the card ───────────────────────────────────────────────────────────────────────────── + Rectangle { + id: card + anchors.centerIn: parent + width: Style.sp(120) + implicitHeight: col.implicitHeight + Style.sp(12) + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + visible: root.shown + opacity: root.shown ? 1 : 0 + scale: root.shown ? 1 : 0.98 + Behavior on opacity { NumberAnimation { duration: Style.motion.swap } } + Behavior on scale { NumberAnimation { duration: Style.motion.swap; easing.type: Easing.OutCubic } } + + // Swallow clicks so they never reach the dismiss layer behind. + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.sp(6) + spacing: Style.sp(5) + + // header + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + text: "SOUND" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: "Sound" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.xl + } + } + IconButton { + Layout.alignment: Qt.AlignTop + icon: "close" + iconSize: Style.fs.md + onClicked: root.closeRequested() + } + } + + // sliders + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + FxRow { + label: "Tempo" + value: root.fx.speed.toFixed(2) + "\u00D7" + from: 0.25; to: 2.0; val: root.fx.speed + onMoved: (v) => root.setSpeed(v) + } + FxRow { + label: "Pitch" + value: (root.fx.semitones > 0 ? "+" : "") + root.fx.semitones + " st" + from: -12; to: 12; val: root.fx.semitones + onMoved: (v) => root.setPitch(v) + } + FxRow { + label: "Reverb" + value: Math.round(root.fx.reverb * 100) + "%" + from: 0; to: 1; val: root.fx.reverb + onMoved: (v) => root.setReverb(v) + } + FxRow { + label: "Bass" + value: (root.fx.bass > 0 ? "+" : "") + root.fx.bass + " dB" + from: -6; to: 12; val: root.fx.bass + onMoved: (v) => root.setBass(v) + } + FxRow { + label: "Width" + value: Math.round(root.fx.width * 100) + "%" + from: 0; to: 1; val: root.fx.width + onMoved: (v) => root.setWidth(v) + } + } + + Hairline { Layout.fillWidth: true } + + // presets + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Text { + text: "PRESETS" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Flow { + Layout.fillWidth: true + spacing: Style.sp(2) + Repeater { + model: root.presets + delegate: Chip { + id: chip + required property var modelData + text: chip.modelData.name + active: root.presetActive(chip.modelData.fx) + onClicked: root.applyPreset(chip.modelData.fx) + } + } + } + } + } + } + + // One slider row: a label, its live value, and the drag control. `moved` carries the raw slider + // value up; the parent quantizes and clamps it per field. + component FxRow: RowLayout { + id: r + property string label: "" + property string value: "" + property real from: 0 + property real to: 1 + property real val: 0 + signal moved(real v) + + Layout.fillWidth: true + spacing: Style.sp(3) + + Text { + Layout.preferredWidth: Style.sp(16) + text: r.label + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + Slider { + Layout.fillWidth: true + from: r.from + to: r.to + value: r.val + onMoved: (v) => r.moved(v) + onCommitted: (v) => r.moved(v) + } + Text { + Layout.preferredWidth: Style.sp(14) + horizontalAlignment: Text.AlignRight + text: r.value + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + } +} diff --git a/client/chrome/TitleBar.qml b/client/chrome/TitleBar.qml new file mode 100644 index 0000000..4014c97 --- /dev/null +++ b/client/chrome/TitleBar.qml @@ -0,0 +1,263 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The top register (spec section 3): the rail toggle and history on the left, then a 280 px search +// field that routes to the search page, and the right cluster — account, Listen Together, Discord, +// the panel toggle, the Sound dialog and the mini window. Under Hyprland the compositor owns the +// window controls, so this bar carries no minimise/close. Discord is a plain setting toggle wired +// here; the rest raise signals the App owns. +Rectangle { + id: root + + signal sidebarToggleClicked() + signal panelToggleClicked() + signal soundClicked() + signal accountClicked(real gx, real gy) + signal listenTogetherClicked() + signal miniClicked() + + property bool sidebarOpen: true + property bool panelOpen: true + property bool discordOn: false + + implicitHeight: Style.titleBarH + color: Tokens.paper + + Component.onCompleted: root.discordOn = !!(Playback.settings && Playback.settings.discord_rpc === "true") + Connections { + target: Playback + function onSettingsChanged() { + root.discordOn = !!(Playback.settings && Playback.settings.discord_rpc === "true"); + } + } + + Hairline { anchors.bottom: parent.bottom; width: parent.width; height: 1 } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(1) + + // --- rail toggle + history ---------------------------------------------------------- + IconButton { + icon: "sidebar" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: root.sidebarOpen + onClicked: root.sidebarToggleClicked() + } + + Rectangle { Layout.preferredWidth: 1; Layout.preferredHeight: Style.sp(4); Layout.alignment: Qt.AlignVCenter; color: Tokens.line } + + IconButton { + icon: "arrow-left" + iconSize: Style.fs.lg + diameter: Style.sp(8) + enabled: Router.canGoBack + onClicked: Router.pop() + } + IconButton { + icon: "arrow-right" + iconSize: Style.fs.lg + diameter: Style.sp(8) + enabled: false + } + + Item { Layout.fillWidth: true } + + // --- provider switch (YouTube Music | Spotify | SoundCloud) ------------------------- + Rectangle { + id: providerPill + Layout.preferredHeight: Style.sp(8) + Layout.alignment: Qt.AlignVCenter + implicitWidth: providerRow.implicitWidth + Style.sp(1.5) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + + RowLayout { + id: providerRow + anchors.fill: parent + anchors.margins: Style.sp(0.75) + spacing: Style.sp(0.75) + Repeater { + model: [ + { key: "youtube", icon: "youtube-music" }, + { key: "spotify", icon: "spotify" }, + { key: "soundcloud", icon: "soundcloud" } + ] + delegate: Rectangle { + id: prov + required property var modelData + readonly property bool selected: Playback.provider === prov.modelData.key + Layout.preferredWidth: Style.sp(7) + Layout.fillHeight: true + radius: Style.radius - 1 + // The selected provider wears its own colour as a soft plate, the one + // place the brand colours appear as chrome; the glyph stays ink. + readonly property color brand: Style.providerColors[prov.modelData.key] || Style.providerColors.youtube + color: prov.selected ? Qt.rgba(prov.brand.r, prov.brand.g, prov.brand.b, 0.22) + : provHover.hovered ? Tokens.tint5 : "transparent" + border.width: prov.selected ? 1 : 0 + border.color: Qt.rgba(prov.brand.r, prov.brand.g, prov.brand.b, 0.55) + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + Icon { + anchors.centerIn: parent + name: prov.modelData.icon + size: Style.fs.lg + color: prov.selected ? prov.brand : Tokens.inkMuted + } + HoverHandler { id: provHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (!prov.selected) Playback.setProvider(prov.modelData.key) + } + } + } + } + } + + // --- search field (routes to the search page) --------------------------------------- + Rectangle { + id: searchField + Layout.preferredWidth: Style.sp(70) + Layout.preferredHeight: Style.sp(8) + Layout.alignment: Qt.AlignVCenter + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: searchInput.activeFocus ? Tokens.lineStrong : Tokens.line + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + + function submit() { + var q = searchInput.text.trim(); + if (q === "") + return; + Router.push("search", { q: q }); + searchInput.text = ""; + searchInput.focus = false; + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2.5) + anchors.rightMargin: Style.sp(1.5) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: searchInput + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + onAccepted: searchField.submit() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: searchInput.text.length === 0 + text: "Search…" + color: Tokens.inkFaint + font: searchInput.font + } + } + IconButton { + visible: searchInput.text.length > 0 + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(6) + onClicked: searchInput.text = "" + } + } + } + + // --- account ------------------------------------------------------------------------ + Item { + id: account + Layout.alignment: Qt.AlignVCenter + implicitWidth: Style.sp(8) + implicitHeight: Style.sp(8) + + Artwork { + anchors.centerIn: parent + visible: !!(Playback.auth && Playback.auth.signedIn && Playback.auth.avatar) + url: (Playback.auth && Playback.auth.avatar) ? Playback.auth.avatar : "" + px: Style.sp(6) + round: true + placeholderIcon: "account" + } + Icon { + anchors.centerIn: parent + visible: !(Playback.auth && Playback.auth.signedIn && Playback.auth.avatar) + name: "account" + size: Style.fs.lg + color: (Playback.auth && Playback.auth.signedIn) ? Tokens.ink : Tokens.inkMuted + } + HoverHandler { id: accountHover } + Rectangle { + anchors.fill: parent + radius: Style.radius + z: -1 + color: accountHover.hovered ? Tokens.tint5 : "transparent" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + var p = account.mapToItem(null, 0, account.height); + root.accountClicked(p.x, p.y); + } + } + } + + Rectangle { Layout.preferredWidth: 1; Layout.preferredHeight: Style.sp(4); Layout.alignment: Qt.AlignVCenter; color: Tokens.line } + + IconButton { + icon: "group" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: !!(Playback.lt && Playback.lt.role && Playback.lt.role !== "none") + onClicked: root.listenTogetherClicked() + } + IconButton { + icon: "discord" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: root.discordOn + onClicked: { + root.discordOn = !root.discordOn; + Daemon.call("set_setting", { key: "discord_rpc", value: root.discordOn ? "true" : "false" }) + .catch(() => { root.discordOn = !root.discordOn; }); + } + } + + Rectangle { Layout.preferredWidth: 1; Layout.preferredHeight: Style.sp(4); Layout.alignment: Qt.AlignVCenter; color: Tokens.line } + + IconButton { + icon: "panel" + iconSize: Style.fs.lg + diameter: Style.sp(8) + active: root.panelOpen + onClicked: root.panelToggleClicked() + } + IconButton { + icon: "sound" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: root.soundClicked() + } + IconButton { + icon: "minimize" + iconSize: Style.fs.lg + diameter: Style.sp(8) + onClicked: root.miniClicked() + } + } +} diff --git a/client/chrome/Toast.qml b/client/chrome/Toast.qml new file mode 100644 index 0000000..592a6de --- /dev/null +++ b/client/chrome/Toast.qml @@ -0,0 +1,67 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The transient message layer. Playback.toast(message, kind) surfaces the daemon's +// playback-error / playback-notice / cover-error / lt-notice strings; the pill fades in at the +// bottom of the surface and a single self-stopping timer clears it. The timer only exists while a +// message is up, so it never contributes to idle wakeups. +Item { + id: root + anchors.fill: parent + + property string message: "" + property string kind: "info" + readonly property bool showing: opacity > 0 + + Connections { + target: Playback + function onToast(message, kind) { + root.message = message; + root.kind = kind || "info"; + pill.opacity = 1; + life.restart(); + } + } + + Timer { id: life; interval: 3400; onTriggered: pill.opacity = 0 } + + Rectangle { + id: pill + opacity: 0 + visible: opacity > 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.sp(24) + implicitWidth: row.implicitWidth + Style.sp(8) + implicitHeight: row.implicitHeight + Style.sp(5) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + Behavior on opacity { NumberAnimation { duration: Style.motion.move; easing.type: Easing.OutQuad } } + + Row { + id: row + anchors.centerIn: parent + spacing: Style.sp(2) + Icon { + anchors.verticalCenter: parent.verticalCenter + size: Style.fs.md + name: root.kind === "error" ? "alert" : root.kind === "success" ? "check-circle" : "info" + color: root.kind === "error" ? Style.alert : root.kind === "success" ? Tokens.sun : Tokens.inkMuted + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.message + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + + // Dismiss on click. + MouseArea { anchors.fill: parent; onClicked: pill.opacity = 0 } + } +} diff --git a/client/components/AddToPlaylist.qml b/client/components/AddToPlaylist.qml new file mode 100644 index 0000000..7811ed8 --- /dev/null +++ b/client/components/AddToPlaylist.qml @@ -0,0 +1,318 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Ryoku.Ui.Singletons +import "../" + +// The "Add to playlist" picker, ported from AddToPlaylist.svelte. A reusable overlay any surface can +// raise with open(songs): it fills its parent, blurs the page behind it once on open (spec §9) under +// a 0.5 scrim, lists the library's editable playlists (On Repeat and Liked Music dropped — one takes +// local play counts, the other takes likes), and adds sequentially so a whole album is a handful of +// calls, never a parallel hammer. A track YouTube already holds is refused and simply not re-counted. +// `blurSource` is the sibling page content to snapshot behind the card; when unset (a Menu-embedded +// picker) it falls back to scrim only. Data flows are unchanged. +Item { + id: root + + anchors.fill: parent + visible: root.open + z: 200 + + property Item blurSource: null + property bool open: false + property var songs: [] + property var playlists: [] + property bool loading: false + property string createName: "" + property bool showCreate: false + + function openWith(list) { + root.songs = list && list.length ? list : []; + if (!root.songs.length) + return; + root.showCreate = false; + root.createName = ""; + root.playlists = []; + root.open = true; + if (root.blurSource) + snap.scheduleUpdate(); + root.load(); + } + + function close() { + root.open = false; + root.showCreate = false; + root.createName = ""; + } + + function load() { + root.loading = true; + Daemon.call("get_library") + .then((p) => { + root.playlists = (p || []).filter((i) => i.kind === "playlist" + && i.id !== "RYOTUNES_ON_REPEAT" && i.id !== "RYOTUNES_RECENTLY_PLAYED" + && i.id !== "RYOTUNES_REDISCOVER" && i.id !== "VLLM"); + root.loading = false; + }) + .catch((e) => { + Playback.toast((e && e.message) ? e.message : "Could not load playlists", "error"); + root.loading = false; + }); + } + + function pick(pl) { + var list = root.songs.slice(); + root.close(); + if (!list.length) + return; + var isLocal = String(pl.id).indexOf("RYOTUNES_LOCAL_PLAYLIST:") === 0; + var added = 0; + var chain = Promise.resolve(); + for (var i = 0; i < list.length; i++) { + (function (song) { + chain = chain.then(() => (isLocal + ? Daemon.call("add_to_local_playlist", { playlistId: pl.id, item: song }) + : Daemon.call("add_to_playlist", { playlistId: pl.id, videoId: song.video_id })) + .then((accepted) => { if (accepted) added++; })); + })(list[i]); + } + chain.then(() => { + var dupes = list.length - added; + if (!added) + Playback.toast(dupes > 1 ? ("All " + dupes + " are already in " + pl.title) : ("Already in " + pl.title), "info"); + else if (dupes) + Playback.toast("Added " + added + " to " + pl.title + " (" + dupes + " already there)", "success"); + else + Playback.toast(added > 1 ? ("Added " + added + " songs to " + pl.title) : ("Added to " + pl.title), "success"); + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not add", "error")); + } + + function createAndPick() { + var title = root.createName.trim(); + if (!title) + return; + Daemon.call("create_playlist", { title: title }) + .then((id) => { + root.showCreate = false; + root.createName = ""; + root.pick({ id: id, title: title }); + }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not create", "error")); + } + + // snapshot blur of the page behind, captured once on open (spec §9) + ShaderEffectSource { + id: snap + anchors.fill: parent + visible: false + sourceItem: root.blurSource + live: false + hideSource: false + recursive: false + } + MultiEffect { + anchors.fill: parent + visible: root.blurSource !== null && Style.blurEnabled + source: snap + blurEnabled: true + blur: 1.0 + blurMax: 32 + autoPaddingEnabled: false + } + + // dismiss layer + scrim + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: 0.5 + } + + Rectangle { + anchors.centerIn: parent + width: Style.sp(90) + implicitHeight: sheet.implicitHeight + Style.sp(12) + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + + // swallow clicks so they don't reach the dismiss layer + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: sheet + anchors.fill: parent + anchors.margins: Style.sp(6) + spacing: Style.sp(3) + + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: "Add to playlist" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.DemiBold + } + IconButton { + icon: "close" + iconSize: Style.fs.md + diameter: Style.sp(8) + onClicked: root.close() + } + } + + // create row / form + Loader { + Layout.fillWidth: true + sourceComponent: root.showCreate ? createForm : createButton + } + + // playlist list + Text { + visible: root.loading + text: "Loading…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, Style.sp(80)) + visible: !root.loading && root.playlists.length > 0 + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: root.playlists + delegate: Rectangle { + id: plRow + required property var modelData + width: ListView.view ? ListView.view.width : 0 + implicitHeight: Style.sp(11) + radius: Style.radius + color: plHover.hovered ? Tokens.tint5 : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Artwork { + url: plRow.modelData.thumbnail ? plRow.modelData.thumbnail : "" + px: Style.sp(9) + placeholderIcon: "playlist" + } + Text { + Layout.fillWidth: true + text: plRow.modelData.title + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + elide: Text.ElideRight + } + } + HoverHandler { id: plHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.pick(plRow.modelData) + } + } + } + Text { + visible: !root.loading && root.playlists.length === 0 + Layout.fillWidth: true + text: "No playlists yet. Create one above and these songs will be added immediately." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + } + + Component { + id: createButton + Rectangle { + implicitHeight: Style.sp(10) + radius: Style.radius + color: cbHover.hovered ? Tokens.tint5 : "transparent" + border.width: 1 + border.color: Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "add"; size: Style.fs.md; color: Tokens.ink } + Text { + Layout.fillWidth: true + text: "New playlist" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + HoverHandler { id: cbHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.showCreate = true } + } + } + + Component { + id: createForm + RowLayout { + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: nameField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: nameField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.createName + focus: true + Component.onCompleted: nameField.forceActiveFocus() + onTextChanged: root.createName = text + onAccepted: root.createAndPick() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: nameField.text.length === 0 + text: "Playlist name" + color: Tokens.inkFaint + font: nameField.font + } + } + } + Rectangle { + implicitWidth: Style.sp(14) + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.bone + Text { + anchors.centerIn: parent + text: "Create" + color: Tokens.inkOnBone + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.createAndPick() } + } + } + } +} diff --git a/client/components/ArtAccent.qml b/client/components/ArtAccent.qml new file mode 100644 index 0000000..91f6409 --- /dev/null +++ b/client/components/ArtAccent.qml @@ -0,0 +1,59 @@ +import QtQuick +import "../" + +// Samples the playing cover's accent into Playback.artAccent: the Svelte artworkAccent algorithm +// (a 24 px downsample, colours bucketed by 3 bits per channel, the bucket with the best +// saturation-times-mid-lightness score wins). One instance per window, because a Canvas only +// paints inside a window that renders: whichever window is visible keeps the accent current, and +// both computing the same URL is a no-op. Invisible, 24 x 24, painted once per track. +Canvas { + id: root + + readonly property string url: (Playback.now && Playback.now.thumbnail) ? Style.thumb(Playback.now.thumbnail, 64) : "" + property string sampled: "" + + width: 24 + height: 24 + visible: false + renderStrategy: Canvas.Immediate + renderTarget: Canvas.Image + + onUrlChanged: { + if (!root.url) { + Playback.artAccent = "transparent"; + root.sampled = ""; + return; + } + if (root.isImageLoaded(root.url)) + root.requestPaint(); + else + root.loadImage(root.url); + } + onImageLoaded: if (root.url && root.isImageLoaded(root.url)) root.requestPaint() + Component.onCompleted: if (root.url) root.loadImage(root.url) + + onPaint: { + if (!root.url || root.sampled === root.url || !root.isImageLoaded(root.url)) + return; + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + ctx.drawImage(root.url, 0, 0, width, height); + var data = ctx.getImageData(0, 0, width, height).data; + var buckets = {}; + var best = null; + for (var i = 0; i < data.length; i += 4) { + if (data[i + 3] < 128) continue; + var r = data[i], g = data[i + 1], b = data[i + 2]; + var max = Math.max(r, g, b) / 255, min = Math.min(r, g, b) / 255; + var sat = max === 0 ? 0 : (max - min) / max; + if (sat < 0.12) continue; + var score = sat * (1 - Math.abs(max - 0.65)); + var key = ((r >> 5) << 6) | ((g >> 5) << 3) | (b >> 5); + var cur = buckets[key] || (buckets[key] = { n: 0, r: 0, g: 0, b: 0, score: 0 }); + cur.n++; cur.r += r; cur.g += g; cur.b += b; cur.score += score; + if (!best || cur.score > best.score) best = cur; + } + root.sampled = root.url; + Playback.artAccent = best ? Qt.rgba(best.r / best.n / 255, best.g / best.n / 255, best.b / best.n / 255, 1) : "transparent"; + } +} diff --git a/client/components/Artwork.qml b/client/components/Artwork.qml new file mode 100644 index 0000000..e64eb69 --- /dev/null +++ b/client/components/Artwork.qml @@ -0,0 +1,151 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// Track/album/artist/playlist artwork with the neutral placeholder every absent or failed thumbnail +// lands on. The URL is rewritten by Style.thumb() to the exact pixel size drawn (2x for crispness) +// so the CDN returns a small image and the decode/cache stays bounded. +// +// Rounded corners cost one layer, not three: the Image renders into its own layer and a tiny +// fragment shader (shaders/rounded.frag) clips it to a rounded rectangle from a signed-distance +// field. The previous MultiEffect mask needed the image layer, a mask layer and the effect's own +// pass per thumbnail, and measured 20% of a core scrolling a 700-track list. +// +// One thumbnail string draws two ways. An ordinary URL — a real cover, a custom override, a +// provider image — is the single Image above. The internal collage contract is the literal marker +// "ryotunes-collage:" followed by a JSON array of exactly four nonempty URL strings; it renders as a +// 2x2 mosaic. The marker is produced only inside the app (a local playlist with four distinct song +// covers) and never leaves it. Anything that fails to parse — a truncated marker, the wrong shape — +// falls back to the placeholder rather than the broken source. +Item { + id: root + + property string url: "" + property int px: 48 + property bool round: false + property string placeholderIcon: "music" + property int glyphSize: Math.round(px * 0.42) + + width: px + height: px + + property real cornerRadius: root.round ? width / 2 : Style.radius + + // A marker URL is one the daemon addressed to this component; only a well-formed one becomes a + // collage. Splitting the two lets a malformed marker skip the single Image (which would try, and + // fail, to load the marker text as a source) and land on the placeholder instead. + readonly property bool markerUrl: root.url.lastIndexOf("ryotunes-collage:", 0) === 0 + readonly property var collageUrls: root.parseCollage(root.url) + readonly property bool isCollage: root.collageUrls !== null + + // Parse once per url change (a plain binding, so no image status feeds back into it): the array + // of four covers, or null when this is an ordinary URL or a malformed marker. + function parseCollage(u) { + if (u.lastIndexOf("ryotunes-collage:", 0) !== 0) + return null; + try { + var arr = JSON.parse(u.substring(17)); + if (Array.isArray(arr) && arr.length === 4) { + for (var i = 0; i < 4; i++) + if (typeof arr[i] !== "string" || arr[i].length === 0) + return null; + return arr; + } + } catch (e) {} + return null; + } + + Rectangle { + id: plate + anchors.fill: parent + radius: root.cornerRadius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineSoft + + Icon { + anchors.centerIn: parent + visible: !root.isCollage && img.status !== Image.Ready + name: root.placeholderIcon + size: root.glyphSize + color: Tokens.inkFaint + } + } + + Image { + id: img + anchors.fill: parent + source: (root.url && !root.markerUrl) ? Style.thumb(root.url, Math.round(root.px * 2)) : "" + sourceSize: Qt.size(Math.round(root.px * 2), Math.round(root.px * 2)) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: !root.isCollage && status === Image.Ready + layer.enabled: true + layer.effect: ShaderEffect { + // The layer's pixel size: the item size times the window's device pixel ratio, which + // is what the SDF needs so the radius is in the same units as the texture. + property vector2d size: Qt.vector2d(img.width * img.Screen.devicePixelRatio, img.height * img.Screen.devicePixelRatio) + property real radius: root.cornerRadius * img.Screen.devicePixelRatio + fragmentShader: Qt.resolvedUrl("../shaders/rounded.frag.qsb") + } + } + + // The 2x2 mosaic, built only while a well-formed marker is present. All four tiles render into + // one layer, so the group takes a single rounded-clip SDF pass — the same cost as the single + // Image, not four. The Loader keeps the same delegates while one collage swaps for another (the + // model is a constant 4 and only the sources rebind), so nothing here recreates itself on load. + Loader { + anchors.fill: parent + active: root.isCollage + visible: root.isCollage + sourceComponent: collage + } + + Component { + id: collage + Item { + id: grid + anchors.fill: parent + layer.enabled: true + layer.effect: ShaderEffect { + property vector2d size: Qt.vector2d(grid.width * grid.Screen.devicePixelRatio, grid.height * grid.Screen.devicePixelRatio) + property real radius: root.cornerRadius * grid.Screen.devicePixelRatio + fragmentShader: Qt.resolvedUrl("../shaders/rounded.frag.qsb") + } + + // Integer halves with complementary far edges: the two columns/rows meet with no seam + // and no overhang at any width. + readonly property int half: Math.floor(grid.width / 2) + + Repeater { + model: 4 + Image { + required property int index + x: (index % 2 === 0) ? 0 : grid.half + y: (index < 2) ? 0 : grid.half + width: (index % 2 === 0) ? grid.half : (grid.width - grid.half) + height: (index < 2) ? grid.half : (grid.height - grid.half) + // Covers are square (thumb() squares Google URLs), so a tile fills its quadrant + // edge to edge; a rare non-square cover fits whole, the plate showing behind it, + // rather than being cropped. + source: root.collageUrls ? Style.thumb(root.collageUrls[index], Math.round(root.px)) : "" + sourceSize: Qt.size(Math.round(root.px), Math.round(root.px)) + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: true + } + } + } + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + color: "transparent" + border.width: 1 + border.color: Tokens.lineSoft + visible: img.visible || root.isCollage + } +} diff --git a/client/components/Backdrop.qml b/client/components/Backdrop.qml new file mode 100644 index 0000000..6b143f9 --- /dev/null +++ b/client/components/Backdrop.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Effects +import Ryoku.Ui.Singletons +import "../" + +// The playing cover as the room's light: a 64 px thumbnail stretched over this item, blurred once +// (one MultiEffect pass per track change; static between), cross-faded A/B on track change, and +// faded toward paper at the edges so it reads as a bloom rather than a poster. `strength` is the +// wash's opacity. The bloom is static (scale 1.03) - see driftScale; `drift` is kept as a +// 10 fps timer so an idle window never renders per vsync; paused, hidden or power-saver: still. +Item { + id: root + + property real strength: 0.22 + // Where the light comes from, as fractions of the item; the edge fade is centred there. + property real focusX: 0.72 + property real focusY: 0.18 + property bool drift: true + + readonly property string url: (Playback.now && Playback.now.thumbnail) ? Style.thumb(Playback.now.thumbnail, 64) : "" + property int front: 0 + readonly property bool paperDark: (Tokens.paper.r + Tokens.paper.g + Tokens.paper.b) / 3 < 0.5 + + onUrlChanged: { + var back = root.front === 0 ? layerB : layerA; + back.src = root.url; + } + Component.onCompleted: layerA.src = root.url + + component Wash: Item { + id: wash + anchors.fill: parent + property string src: "" + property bool shown: false + readonly property bool ready: img.status === Image.Ready + opacity: shown ? root.strength * Style.wash : 0 + Behavior on opacity { NumberAnimation { duration: Tokens.durSlowEffects; easing.type: Easing.OutCubic } } + Image { + id: img + anchors.fill: parent + source: wash.src + sourceSize: Qt.size(64, 64) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + onStatusChanged: if (status === Image.Ready) { + // The newest loaded layer takes the front; the other fades out behind it. + root.front = wash === layerA ? 0 : 1; + layerA.shown = root.front === 0; + layerB.shown = root.front === 1; + } + } + MultiEffect { + anchors.fill: parent + source: img + visible: wash.ready && Style.blurEnabled + blurEnabled: true + blur: 1.0 + blurMax: 64 + saturation: root.paperDark ? 0.15 : -0.1 + brightness: root.paperDark ? 0.05 : 0.1 + scale: driftScale.value + transformOrigin: Item.Center + } + } + Wash { id: layerA } + Wash { id: layerB } + + // The bloom's edge: paper, transparent at the focus, painted once per resize. + Canvas { + id: fade + anchors.fill: parent + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: { + var c = getContext("2d"); + c.clearRect(0, 0, width, height); + var p = Tokens.paper; + var g = c.createRadialGradient(width * root.focusX, height * root.focusY, 0, width * root.focusX, height * root.focusY, Math.max(width, height) * 0.9); + g.addColorStop(0, Qt.rgba(p.r, p.g, p.b, 0)); + g.addColorStop(0.55, Qt.rgba(p.r, p.g, p.b, 0.55)); + g.addColorStop(1, Qt.rgba(p.r, p.g, p.b, 0.95)); + c.fillStyle = g; + c.fillRect(0, 0, width, height); + } + Connections { target: Tokens; function onPaperChanged() { fade.requestPaint(); } } + } + + // The wash is static between track changes. A continuous drift (a 6 % scale sine over 12 s) + // was invisible on a blurred field but forced the scene graph to re-render every frame while + // playing - measured at 35 % of a core in the client. One blur pass per track, then nothing. + QtObject { + id: driftScale + readonly property real value: 1.03 + } +} diff --git a/client/components/Btn.qml b/client/components/Btn.qml new file mode 100644 index 0000000..dec32dc --- /dev/null +++ b/client/components/Btn.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// The text button: 36 px, radius 8, md 500. `primary` is the one filled control on a surface +// (bone on paper, Ryoku's inverse pair); everything else is a hairline ghost. Press scales 0.97, +// hover lifts the border, both on Tokens' snap timing. +Item { + id: root + + property string text: "" + property string icon: "" + property bool primary: false + signal clicked() + + implicitWidth: row.implicitWidth + Style.sp(4) * 2 + implicitHeight: Style.ctlH + opacity: enabled ? 1 : 0.45 + activeFocusOnTab: true + Keys.onReturnPressed: event => { if (root.enabled) root.clicked(); event.accepted = true; } + Keys.onSpacePressed: event => { if (root.enabled) root.clicked(); event.accepted = true; } + + Rectangle { + anchors.fill: parent + radius: Style.radius + color: root.primary ? Tokens.bone : (press.pressed ? Tokens.tint16 : hover.hovered ? Tokens.tint10 : "transparent") + border.width: root.activeFocus ? 2 : root.primary ? 0 : 1 + border.color: root.primary && root.activeFocus ? Tokens.inkOnBone + : root.activeFocus || hover.hovered ? Tokens.lineStrong : Tokens.line + scale: press.pressed ? 0.97 : 1 + Behavior on scale { NumberAnimation { duration: Style.motion.snap; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + RowLayout { + id: row + anchors.centerIn: parent + spacing: Style.sp(2) + Icon { + visible: root.icon !== "" + name: root.icon + size: Style.fs.md + color: root.primary ? Tokens.inkOnBone : Tokens.ink + } + Text { + visible: root.text !== "" + text: root.text + color: root.primary ? Tokens.inkOnBone : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + } + } + } + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { id: press; onTapped: root.clicked() } + Accessible.role: Accessible.Button + Accessible.name: root.text + Accessible.onPressAction: { if (root.enabled) root.clicked(); } +} diff --git a/client/components/CardGrid.qml b/client/components/CardGrid.qml new file mode 100644 index 0000000..cdc5987 --- /dev/null +++ b/client/components/CardGrid.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// A reused grid of MediaCards — the library's card tabs, a radio station wall, an artist's +// releases. A GridView with reuseItems and a bounded cache keeps a long collection to a couple of +// screenfuls of delegates. Columns = max(2, floor(width / (cardW + sp(4)))), per spec section 4. +Item { + id: root + + property var model: [] + property bool loading: false + property string emptyText: "Nothing here." + property int pad: Style.pagePad + + readonly property int columns: Math.max(2, Math.floor(width / (Style.cardW + Style.sp(4)))) + + GridView { + id: grid + anchors.fill: parent + anchors.leftMargin: root.pad + anchors.rightMargin: root.pad + topMargin: Style.sp(4) + bottomMargin: Style.sp(20) + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + cellWidth: Math.floor(width / Math.max(1, root.columns)) + cellHeight: grid.cellWidth + Style.sp(16) + model: root.loading ? [] : root.model + + delegate: Item { + required property var modelData + width: grid.cellWidth + height: grid.cellHeight + MediaCard { + anchors.horizontalCenter: parent.horizontalCenter + item: parent.modelData + cardWidth: grid.cellWidth - Style.sp(4) + } + } + } + + Text { + anchors.centerIn: parent + visible: root.loading || (root.model.length === 0) + text: root.loading ? "Loading…" : root.emptyText + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } +} diff --git a/client/components/Chip.qml b/client/components/Chip.qml new file mode 100644 index 0000000..ef78e1d --- /dev/null +++ b/client/components/Chip.qml @@ -0,0 +1,39 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// A mood/filter pill from the Home chip rail: a 32 px fully-rounded pill, outlined at rest and +// filled with the bone plate when it is the active filter, so the one selected chip is the only +// solid thing above the feed (per the Svelte chipClass and spec section 4). +Rectangle { + id: root + + property string text: "" + property bool active: false + signal clicked() + + implicitHeight: Style.sp(8) + implicitWidth: label.implicitWidth + Style.sp(8) + radius: height / 2 + color: active ? Tokens.bone : chipHover.hovered ? Tokens.tint10 : "transparent" + border.width: 1 + border.color: active ? Tokens.bone : chipHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: root.active ? Tokens.inkOnBone : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + } + + HoverHandler { id: chipHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/client/components/DownloadButton.qml b/client/components/DownloadButton.qml new file mode 100644 index 0000000..43cde77 --- /dev/null +++ b/client/components/DownloadButton.qml @@ -0,0 +1,187 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Shapes +import Ryoku.Ui.Singletons +import "../" + +// The one-click "save this track" control that sits beside the like heart. It reads the shared +// Downloads mirror for the playing track's job and reflects its state on the same plate the other +// transport buttons use: a download arrow to start, a progress ring while it runs, a check when it +// lands, an alert to retry a failure, and a muted arrow with an explanatory tooltip when the track +// (local file, live radio) can't be saved. One click enqueues immediately (Downloads.enqueue toasts +// the confirmation); clicking an in-flight or finished track opens the Downloads page instead, and a +// failed/cancelled one retries. It is a focusable, labelled control in its own right — Space/Return +// activate it, Tab reaches it — so it never depends on rewriting the shared IconButton. +Item { + id: root + + // The track to act on; defaults to whatever is playing (Playback.now shape). + property var track: Playback.now + property int diameter: Style.sp(8) + property int iconSize: Style.fs.md + + // The job for this track, reactive to the Downloads queue (jobFor reads Downloads.jobs). + readonly property var job: Downloads.jobFor(root.track ? root.track.videoId : "") + readonly property string status: root.job ? String(root.job.status) : "" + readonly property bool downloadable: Downloads.canDownload(root.track) + readonly property int progress: (root.job && root.job.progress) ? Math.max(0, Math.min(100, root.job.progress)) : 0 + readonly property bool busy: root.status === "downloading" || root.status === "queued" + + readonly property string iconName: root.status === "completed" ? "check-circle" + : root.status === "failed" ? "alert" + : "download" + readonly property color tint: root.status === "completed" ? Style.accent + : root.status === "failed" ? Style.alert + : root.busy ? Tokens.ink + : root.downloadable ? Tokens.inkMuted + : Tokens.inkFaint + + // The hover tooltip line and the assistive-tech name for the current state. + readonly property string hint: { + switch (root.status) { + case "completed": return "Downloaded \u00b7 open downloads"; + case "downloading": return "Downloading " + root.progress + "% \u00b7 open downloads"; + case "queued": return "Queued \u00b7 open downloads"; + case "failed": return (root.job && root.job.error ? "Failed: " + root.job.error : "Download failed") + " \u00b7 retry"; + case "cancelled": return "Cancelled \u00b7 retry"; + default: return Downloads.reason(root.track); + } + } + readonly property string a11yName: { + switch (root.status) { + case "completed": return "Downloaded. Open downloads."; + case "downloading": return "Downloading, " + root.progress + " percent. Open downloads."; + case "queued": return "Queued for download. Open downloads."; + case "failed": return "Download failed. Retry."; + case "cancelled": return "Download cancelled. Retry."; + default: return root.downloadable ? "Download this track." : Downloads.reason(root.track); + } + } + + function activate() { + var t = root.track; + if (!t || !t.videoId) + return; + var j = root.job; + if (j) { + if (j.status === "completed" || j.status === "downloading" || j.status === "queued") + Router.push("downloads"); + else + Downloads.retry(j.id); // failed or cancelled reuse the record + return; + } + if (!root.downloadable) { + Playback.toast(Downloads.reason(t), "info"); + return; + } + Downloads.enqueue(t); + } + + implicitWidth: diameter + implicitHeight: diameter + opacity: (root.downloadable || root.job) ? 1 : 0.55 + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: root.a11yName + Accessible.description: root.hint + Accessible.onPressAction: root.activate() + Keys.onSpacePressed: (e) => { root.activate(); e.accepted = true; } + Keys.onReturnPressed: (e) => { root.activate(); e.accepted = true; } + Keys.onEnterPressed: (e) => { root.activate(); e.accepted = true; } + + // The plate, matching IconButton's hover/press tints so it reads as one of the transport row. + Rectangle { + anchors.fill: parent + radius: Style.radius + color: ma.pressed ? Tokens.tint16 + : hover.hovered ? Tokens.tint10 + : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + + // The keyboard focus ring. + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Style.radius + color: "transparent" + border.width: root.activeFocus ? 2 : 0 + border.color: Tokens.ink + } + + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.tint + } + + // The progress ring while queued (empty accent track) or downloading (accent arc to progress). + Shape { + id: ring + anchors.centerIn: parent + visible: root.busy + width: root.diameter + height: root.diameter + preferredRendererType: Shape.CurveRenderer + readonly property real cx: width / 2 + readonly property real cy: height / 2 + readonly property real rad: root.diameter * 0.36 + + ShapePath { + strokeColor: Style.accentSoft + strokeWidth: 2 + fillColor: "transparent" + PathAngleArc { centerX: ring.cx; centerY: ring.cy; radiusX: ring.rad; radiusY: ring.rad; startAngle: 0; sweepAngle: 360 } + } + ShapePath { + strokeColor: Style.accent + strokeWidth: 2 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + PathAngleArc { + centerX: ring.cx; centerY: ring.cy; radiusX: ring.rad; radiusY: ring.rad + startAngle: -90 + sweepAngle: 360 * root.progress / 100 + } + } + } + + // Hover/focus tooltip. One line, elided; fades unless reduced motion is asked for. + Rectangle { + id: tip + visible: opacity > 0 + opacity: ((hover.hovered || root.activeFocus) && root.hint !== "") ? 1 : 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.top + anchors.bottomMargin: Style.sp(1.5) + implicitWidth: tipText.width + Style.sp(3) + implicitHeight: tipText.implicitHeight + Style.sp(2) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + z: 100 + Behavior on opacity { enabled: !Tokens.reduceMotion; NumberAnimation { duration: Style.motion.snap } } + Text { + id: tipText + anchors.centerIn: parent + width: Math.min(Style.sp(56), implicitWidth) + text: root.hint + textFormat: Text.PlainText + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.xs + elide: Text.ElideRight + } + } + + HoverHandler { id: hover } + MouseArea { + id: ma + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.activate() + } +} diff --git a/client/components/EditPlaylist.qml b/client/components/EditPlaylist.qml new file mode 100644 index 0000000..0b3a72b --- /dev/null +++ b/client/components/EditPlaylist.qml @@ -0,0 +1,180 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Ryoku.Ui.Singletons +import "../" + +// "Edit playlist" details, ported from EditPlaylistDialog.svelte: name, description and visibility. +// edit_playlist_details merges only the fields sent, so an untouched one is never overwritten; the +// page reloads on save to pick up whatever YouTube accepted. A centred modal card over a one-shot +// snapshot blur of the page behind (spec §9) under a 0.5 scrim. Data flows are unchanged. +Item { + id: root + + anchors.fill: parent + z: 210 + + property Item blurSource: null + property string playlistId: "" + property string initialName: "" + property string initialDescription: "" + property bool initialPublic: false + property bool saving: false + + signal closed() + signal saved() + + property string nameText: initialName + property string descText: initialDescription + property bool isPublic: initialPublic + + Component.onCompleted: { if (root.blurSource) snap.scheduleUpdate(); } + + function submit() { + if (root.saving || !root.nameText.trim()) + return; + root.saving = true; + Daemon.call("edit_playlist_details", { + playlistId: root.playlistId, + name: root.nameText.trim(), + description: root.descText, + public: root.isPublic + }).then(() => { root.saving = false; root.saved(); }) + .catch((e) => { root.saving = false; Playback.toast((e && e.message) ? e.message : "Could not save", "error"); }); + } + + // snapshot blur of the page behind, captured once on open (spec §9) + ShaderEffectSource { + id: snap + anchors.fill: parent + visible: false + sourceItem: root.blurSource + live: false + hideSource: false + recursive: false + } + MultiEffect { + anchors.fill: parent + visible: root.blurSource !== null && Style.blurEnabled + source: snap + blurEnabled: true + blur: 1.0 + blurMax: 32 + autoPaddingEnabled: false + } + + MouseArea { anchors.fill: parent; onClicked: root.closed() } + Rectangle { anchors.fill: parent; color: "#000000"; opacity: 0.5 } + + Rectangle { + anchors.centerIn: parent + width: Style.sp(100) + implicitHeight: col.implicitHeight + Style.sp(12) + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: col + anchors.fill: parent + anchors.margins: Style.sp(6) + spacing: Style.sp(3) + + Text { + text: "Edit playlist" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.DemiBold + } + + // name + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: nameField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: nameField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.nameText + focus: true + Component.onCompleted: nameField.forceActiveFocus() + onTextChanged: root.nameText = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: nameField.text.length === 0 + text: "Playlist name" + color: Tokens.inkFaint + font: nameField.font + } + } + } + + // description + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(24) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: descField.activeFocus ? Tokens.lineStrong : Tokens.line + TextEdit { + id: descField + anchors.fill: parent + anchors.margins: Style.sp(2) + clip: true + wrapMode: TextEdit.Wrap + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: root.descText + onTextChanged: root.descText = text + Text { + visible: descField.text.length === 0 + text: "Description" + color: Tokens.inkFaint + font: descField.font + } + } + } + + // public toggle + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Toggle { + checked: root.isPublic + onToggled: (v) => root.isPublic = v + } + Text { + Layout.fillWidth: true + text: root.isPublic ? "Public" : "Private" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Style.sp(2) + Pill { label: "Cancel"; onClicked: root.closed() } + Pill { label: "Save"; primary: true; enabled: !root.saving && root.nameText.trim().length > 0; onClicked: root.submit() } + } + } + } +} diff --git a/client/components/Hairline.qml b/client/components/Hairline.qml new file mode 100644 index 0000000..7760d13 --- /dev/null +++ b/client/components/Hairline.qml @@ -0,0 +1,13 @@ +import QtQuick +import Ryoku.Ui.Singletons + +// A one-pixel structural hairline, the recurring divider of the design language. Horizontal by +// default (fill the row, one device pixel tall); a caller sets Layout.fillWidth or an explicit +// width/height. Colour follows the resolved ink through Tokens, never a hardcoded grey. +Rectangle { + property bool soft: false + property bool strong: false + implicitHeight: 1 + implicitWidth: 1 + color: strong ? Tokens.lineStrong : soft ? Tokens.lineSoft : Tokens.line +} diff --git a/client/components/HomeFeed.qml b/client/components/HomeFeed.qml new file mode 100644 index 0000000..f55b3ad --- /dev/null +++ b/client/components/HomeFeed.qml @@ -0,0 +1,28 @@ +import QtQuick + +// Home's asynchronous header can grow before the first scroll. Pin its origin only +// until the user takes control. Restoring contentY's old value (usually zero) when +// releasing the binding skips the entire tall header on the first wheel tick. +ListView { + id: feed + property bool stickTop: true + + onMovementStarted: feed.stickTop = false + Binding { + target: feed + property: "contentY" + value: feed.originY + when: feed.stickTop + restoreMode: Binding.RestoreNone + } + WheelHandler { + target: null + blocking: false + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + if (event.angleDelta.y !== 0 || event.pixelDelta.y !== 0) + feed.stickTop = false; + event.accepted = false; + } + } +} diff --git a/client/components/HomePersonal.qml b/client/components/HomePersonal.qml new file mode 100644 index 0000000..605dcbe --- /dev/null +++ b/client/components/HomePersonal.qml @@ -0,0 +1,479 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../lib/browse.js" as Browse +import "../lib/ids.js" as Ids + +// Home's personal blocks, the ones the original Ryotunes built its Home around: "Jump back in" as a +// two-column grid of what you opened last (art 48, title, meta, play on hover); "Familiar artists" +// as ONE bordered panel — the numbered ARTIST INDEX on the left, the selected artist's photo bleeding +// to the panel's edges in the centre, and the SELECTED ARTIST inspector (eyebrow, Fraunces name, TOP +// TRACKS) on the right; and "Listen again" as the feed's numbered two-column song list. Each section +// is headed by a glyph + `// Title`, 40 px below the one above it and 16 px above its own content. +ColumnLayout { + id: root + + // Personal.recent(n) rows for Jump back in. + property var recents: [] + // Artist pages (get_artist results) for the index, most played first. + property var artists: [] + // The feed's "Listen again" section (cards), or null. + property var listenAgain: null + + spacing: Style.sp(10) + + // ── jump back in ────────────────────────────────────────────────────────────────────── + function openRecent(it) { + if (!it) + return; + if (it.kind === "song") + root.playRecent(it); + else + Router.push(it.kind, { id: it.id, title: it.title }); + Personal.touchPick(it.id); + } + function playRecent(it) { + if (!it) + return; + if (it.kind === "song") { + Playback.play(Browse.asSong(it)) + .catch(() => Playback.toast("Could not play this song", "error")); + return; + } + if (it.kind !== "album" && it.kind !== "playlist") + return; + Daemon.call(it.kind === "album" ? "get_album" : "get_playlist", { id: it.id }) + .then((media) => Daemon.call("play_playlist", { + items: media.items, + sourceId: it.kind === "album" ? media.playlistId + : (Ids.isSmartPlaylistId(it.id) ? undefined : it.id), + sourceName: it.title, + continuation: media.continuation + }).then(() => Personal.noteRecent(it))) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } + + function recentMeta(it) { + if (!it) + return ""; + var kind = it.kind === "song" ? "Song" : it.kind === "album" ? "Album" + : it.kind === "playlist" ? "Playlist" : "Artist"; + var age = Math.max(0, Date.now() - Number(it.at || 0)); + var when = !it.at ? "" : age < 3600000 ? "Recently" + : age < 86400000 ? "Today" : age < 172800000 ? "Yesterday" + : Math.floor(age / 86400000) + " days ago"; + return [kind, it.subtitle || "", when].filter((part) => !!part).join(" · "); + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.recents.length > 0 + spacing: Style.sp(4) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Icon { Layout.alignment: Qt.AlignVCenter; name: "jump-back"; size: Style.fs.md; color: Tokens.inkMuted } + SectionHeading { Layout.fillWidth: true; title: "Recently played"; mark: Style.decorRich ? "戻" : "" } + } + GridLayout { + id: recGrid + Layout.fillWidth: true + columns: width >= Style.sp(150) ? 2 : 1 + columnSpacing: Style.sp(8) + rowSpacing: Style.sp(1) + Repeater { + model: root.recents + delegate: Item { + id: recRow + required property var modelData + readonly property bool round: recRow.modelData && recRow.modelData.kind === "artist" + Layout.fillWidth: true + Layout.preferredWidth: 1 + implicitHeight: Style.sp(14) + + Rectangle { + anchors.fill: parent + radius: Style.radius + color: recHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Artwork { + Layout.alignment: Qt.AlignVCenter + url: recRow.modelData && recRow.modelData.thumbnail ? recRow.modelData.thumbnail : "" + px: Style.sp(12) + round: recRow.round + placeholderIcon: recRow.round ? "user" + : (recRow.modelData && Ids.isOnRepeatId(recRow.modelData.id)) ? "on-repeat" : "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: recRow.modelData ? recRow.modelData.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.recentMeta(recRow.modelData) + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + IconButton { + visible: !recRow.round && recHover.hovered + icon: "play" + iconSize: Style.fs.sm + diameter: Style.sp(8) + onClicked: root.playRecent(recRow.modelData) + tip: "Play " + (recRow.modelData ? recRow.modelData.title : "") + } + } + HoverHandler { id: recHover } + TapHandler { onTapped: root.openRecent(recRow.modelData) } + } + } + } + } + + // ── familiar artists: the index, the bleeding photo and the inspector ────────────────── + property string activeId: "" + readonly property var active: { + for (var i = 0; i < root.artists.length; i++) + if (root.artists[i].channelId === root.activeId) + return root.artists[i]; + return root.artists.length ? root.artists[0] : null; + } + readonly property var indexArtists: root.artists.slice(0, 5) + function activeMeta(a) { + if (!a) + return ""; + return a.monthlyListeners ? a.monthlyListeners : (a.subscribers ? a.subscribers : ""); + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.artists.length >= 3 + spacing: Style.sp(4) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Icon { Layout.alignment: Qt.AlignVCenter; name: "group"; size: Style.fs.md; color: Tokens.inkMuted } + SectionHeading { Layout.fillWidth: true; title: "// Familiar artists"; mark: Style.decorRich ? "馴" : "" } + } + + // the panel — one bordered card, three columns, the photo bleeding between them + Rectangle { + id: famPanel + Layout.fillWidth: true + implicitHeight: famRow.implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + clip: true + + RowLayout { + id: famRow + anchors.fill: parent + spacing: 0 + + // the index + ColumnLayout { + Layout.preferredWidth: Style.sp(106) + Layout.minimumWidth: Style.sp(60) + Layout.fillHeight: true + Layout.alignment: Qt.AlignTop + Layout.margins: Style.sp(3) + spacing: Style.sp(1) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { text: "// ARTIST INDEX"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Item { Layout.fillWidth: true } + Text { text: root.artists.length + " KNOWN"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + } + Rectangle { Layout.fillWidth: true; Layout.topMargin: Style.sp(0.5); Layout.preferredHeight: 1; color: Tokens.lineSoft } + + Repeater { + model: root.indexArtists + delegate: Rectangle { + id: artRow + required property var modelData + required property int index + readonly property bool sel: root.active && root.active.channelId === artRow.modelData.channelId + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + implicitHeight: Style.sp(12) + radius: Style.radius + color: artRow.sel ? Tokens.paperLift : (artHover.hovered ? Tokens.tint5 : "transparent") + border.width: artRow.sel ? 1 : 0 + border.color: Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2.5) + Text { + Layout.preferredWidth: Style.sp(5) + text: (artRow.index + 1 < 10 ? "0" : "") + (artRow.index + 1) + color: artRow.sel ? Tokens.inkMuted : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Artwork { + Layout.alignment: Qt.AlignVCenter + url: artRow.modelData.thumbnail ? artRow.modelData.thumbnail : "" + px: Style.sp(9) + round: true + placeholderIcon: "user" + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: artRow.modelData.name ? artRow.modelData.name : "Artist" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: artRow.modelData.monthlyListeners ? artRow.modelData.monthlyListeners + : (artRow.modelData.subscribers ? artRow.modelData.subscribers : "Artist") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + Text { + text: artRow.sel ? "//" : (Style.decorRich ? "聴" : "") + color: artRow.sel ? Tokens.ink : Tokens.inkFaint + font.family: artRow.sel ? Style.fontMono : Tokens.jp + font.pixelSize: Style.fs.sm + } + } + HoverHandler { id: artHover } + TapHandler { + onTapped: root.activeId = artRow.modelData.channelId + onDoubleTapped: Router.push("artist", { id: artRow.modelData.channelId, title: artRow.modelData.name }) + } + } + } + Item { Layout.fillHeight: true; Layout.preferredHeight: 1 } + } + + Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: Tokens.line } + + // the selected artist's photo, bleeding to the panel's top and bottom edges + Item { + id: photo + Layout.fillHeight: true + Layout.preferredWidth: famPanel.height + Layout.minimumWidth: Style.sp(28) + Layout.maximumWidth: famPanel.height + visible: !!root.active + clip: true + + Rectangle { anchors.fill: parent; color: Tokens.paperLift } + Icon { + anchors.centerIn: parent + visible: photoImg.status !== Image.Ready + name: "user" + size: Style.sp(12) + color: Tokens.inkFaint + } + Image { + id: photoImg + anchors.fill: parent + source: root.active && root.active.thumbnail ? Style.thumb(root.active.thumbnail, 480) : "" + sourceSize: Qt.size(480, 480) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Style.motion.slow } } + } + } + + Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: Tokens.line } + + // the inspector + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: Style.sp(52) + Layout.fillHeight: true + Layout.alignment: Qt.AlignTop + Layout.margins: Style.sp(4) + spacing: Style.sp(1) + visible: !!root.active + + Text { + Layout.fillWidth: true + text: "SELECTED ARTIST" + (root.activeMeta(root.active) ? (" · " + root.activeMeta(root.active).toUpperCase()) : "") + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + Text { + id: selName + Layout.fillWidth: true + Layout.topMargin: Style.sp(0.5) + text: (root.active && root.active.name) ? root.active.name : "Artist" + color: nameHover.hovered ? Tokens.inkDim : Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.hero + elide: Text.ElideRight + HoverHandler { id: nameHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: if (root.active) Router.push("artist", { id: root.active.channelId, title: root.active.name }) } + } + + Item { Layout.fillHeight: true; Layout.preferredHeight: Style.sp(2) } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + visible: !!(root.active && root.active.topSongs && root.active.topSongs.length) + Text { text: "TOP TRACKS"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Rectangle { Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; Layout.preferredHeight: 1; color: Tokens.lineSoft } + } + Repeater { + model: (root.active && root.active.topSongs) ? root.active.topSongs.slice(0, 4) : [] + delegate: Item { + id: topRow + required property var modelData + required property int index + Layout.fillWidth: true + implicitHeight: Style.sp(9) + Rectangle { + anchors.fill: parent + radius: Style.radius + color: topHover.hovered ? Tokens.tint5 : "transparent" + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Text { + Layout.preferredWidth: Style.sp(5) + text: (topRow.index + 1 < 10 ? "0" : "") + (topRow.index + 1) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + text: topRow.modelData.title ? topRow.modelData.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + elide: Text.ElideRight + } + Text { + text: topRow.modelData.duration ? topRow.modelData.duration : "" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + IconButton { + visible: topHover.hovered + icon: "play" + iconSize: Style.fs.sm + diameter: Style.sp(7) + onClicked: Playback.play(topRow.modelData) + } + } + HoverHandler { id: topHover } + TapHandler { onTapped: Playback.play(topRow.modelData) } + } + } + Item { Layout.fillHeight: true; Layout.preferredHeight: 1 } + } + } + } + } + + // ── listen again: the feed's own section as a numbered two-column list ─────────────── + readonly property var listenSongs: (root.listenAgain && root.listenAgain.items) + ? root.listenAgain.items.filter((i) => i.kind === "song").slice(0, 10) : [] + function playListen(start) { + Daemon.call("play_playlist", { + items: root.listenSongs.map(Browse.asSong), + start: start, + sourceName: root.listenAgain ? root.listenAgain.title : null + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.listenSongs.length > 0 + spacing: Style.sp(4) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Icon { Layout.alignment: Qt.AlignVCenter; name: "on-repeat"; size: Style.fs.md; color: Tokens.inkMuted } + SectionHeading { + Layout.fillWidth: true + title: "// " + (root.listenAgain ? root.listenAgain.title : "Listen again") + mark: Style.decorRich ? "再" : "" + more: !!(root.listenAgain && root.listenAgain.moreBrowseId) + onMoreClicked: if (root.listenAgain && root.listenAgain.moreBrowseId) + Router.push("list", { id: root.listenAgain.moreBrowseId, title: root.listenAgain.title }) + } + } + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: Style.sp(8) + rowSpacing: 0 + flow: GridLayout.TopToBottom + rows: Math.ceil(root.listenSongs.length / 2) + Repeater { + model: root.listenSongs + delegate: Item { + id: laRow + required property var modelData + required property int index + readonly property var song: Browse.asSong(laRow.modelData) + Layout.fillWidth: true + Layout.preferredWidth: 1 + implicitHeight: laTrack.implicitHeight + TrackRow { + id: laTrack + anchors.left: parent.left + anchors.right: parent.right + index: laRow.index + song: laRow.song + active: !!(Playback.now && laRow.song && Playback.now.videoId === laRow.song.video_id) + onPlay: root.playListen(laRow.index) + } + } + } + } + } +} diff --git a/client/components/Icon.qml b/client/components/Icon.qml new file mode 100644 index 0000000..e4d4f0a --- /dev/null +++ b/client/components/Icon.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Shapes +import Ryoku.Ui.Singletons +import "../lib/glyphs.js" as Glyphs + +// One icon renderer for the whole client. The Hugeicons free-set glyphs the Svelte UI imports are +// baked into lib/glyphs.js as 24x24 stroke paths and drawn as scene-graph geometry, the way the +// Ryoku shell's GlyphIcon does it. No image, no offscreen layer, no colorization pass: a tint is a +// stroke colour, and a list of a thousand rows pays one geometry node per icon. (The previous +// Image + layer.effect MultiEffect version cost two offscreen passes per track row.) +Item { + id: root + + property string name: "" + property color color: Tokens.ink + property int size: 18 + + width: size + height: size + + readonly property var glyph: Glyphs.glyph(root.name) + readonly property real u: root.size / 24 + + Shape { + anchors.fill: parent + visible: root.glyph !== null + preferredRendererType: Shape.CurveRenderer + transform: Scale { xScale: root.u; yScale: root.u } + ShapePath { + strokeColor: root.color + strokeWidth: root.glyph ? root.glyph.w : 1.5 + fillColor: "transparent" + capStyle: root.glyph && root.glyph.cap === "round" ? ShapePath.RoundCap : ShapePath.FlatCap + joinStyle: ShapePath.RoundJoin + PathSvg { path: root.glyph ? root.glyph.d : "" } + } + } +} diff --git a/client/components/IconButton.qml b/client/components/IconButton.qml new file mode 100644 index 0000000..f432ccb --- /dev/null +++ b/client/components/IconButton.qml @@ -0,0 +1,58 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// A square icon button: hover and pressed tints from Tokens, a disabled dim, and an `active` state +// that pins the "on" ink like the Svelte transport toggles (shuffle/repeat/like). `primary` is the +// one filled control (play/pause), drawn on the bone plate so it keeps contrast on any palette. +Item { + id: root + + property string icon: "" + property int iconSize: 18 + property int diameter: Style.sp(8) + property bool active: false + property bool primary: false + // A hairline ring, for a button that sits beside a Btn in an action row. + property bool outlined: false + property string tip: "" + property color iconColor: root.primary ? Tokens.inkOnBone + : !root.enabled ? Tokens.inkFaint + : root.active ? Tokens.ink + : Tokens.inkMuted + + readonly property alias hovered: hover.hovered + signal clicked() + + implicitWidth: diameter + implicitHeight: diameter + opacity: enabled ? 1 : 0.45 + + Rectangle { + anchors.fill: parent + radius: Style.radius + color: root.primary ? Tokens.bone + : ma.pressed ? Tokens.tint16 + : hover.hovered ? Tokens.tint10 + : "transparent" + border.width: root.outlined ? 1 : 0 + border.color: hover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + + Icon { + anchors.centerIn: parent + name: root.icon + size: root.iconSize + color: root.iconColor + } + + HoverHandler { id: hover; enabled: root.enabled } + MouseArea { + id: ma + anchors.fill: parent + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/client/components/LibrarySongs.qml b/client/components/LibrarySongs.qml new file mode 100644 index 0000000..924360c --- /dev/null +++ b/client/components/LibrarySongs.qml @@ -0,0 +1,239 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// The library Songs tab, ported from LibrarySongs.svelte: the account's saved songs, which browse +// like a playlist (FEmusic_liked_videos). One get_playlist + get_playlist_more on the scroll +// sentinel; a reused TrackList keeps the five-figure list cheap. The filter narrows the rows loaded +// so far, and Play All / Shuffle All seed the queue with the whole list (continuation and all). +Item { + id: root + + // Signed in to YouTube Music: its liked videos. Otherwise the device's own Liked Songs, + // the local playlist the heart writes to (the daemon's LIKED_SONGS_ID). + readonly property bool youtubeLibrary: !!(Playback.auth && Playback.auth.signedIn) + readonly property string songsId: root.youtubeLibrary ? "FEmusic_liked_videos" : "RYOTUNES_LOCAL_PLAYLIST:liked" + readonly property string sourceName: root.youtubeLibrary ? "Your songs" : "Liked Songs" + onSongsIdChanged: root.load() + + // The heart changed the device list (or another client did): re-read it in place. + Connections { + target: Daemon + function onEvent(name: string, data: var): void { + if (name === "library-changed" && data && data.id === root.songsId) + root.load(); + } + } + + property var pl: null + property bool loading: true + property string errorMsg: "" + property bool loadingMore: false + property bool moreError: false + property bool started: false + property string query: "" + + readonly property var shown: { + var items = (root.pl && root.pl.items) ? root.pl.items : []; + var q = root.query.trim().toLowerCase(); + if (!q) + return items; + return items.filter((t) => ( + (t.title && t.title.toLowerCase().indexOf(q) >= 0) + || (t.artists && t.artists.toLowerCase().indexOf(q) >= 0) + || (t.album && t.album.toLowerCase().indexOf(q) >= 0))); + } + + function ensureLoaded() { + if (root.started) + return; + root.started = true; + root.load(); + } + function load() { + root.loading = true; + root.errorMsg = ""; + Daemon.call("get_playlist", { id: root.songsId }) + .then((p) => { root.pl = p; root.loading = false; Qt.callLater(root.scrollTop); }) + .catch((e) => { root.errorMsg = (e && e.message) ? e.message : String(e); root.loading = false; }); + } + + function scrollTop() { + if (body.visible && body.view) + body.view.positionViewAtBeginning(); + } + function loadMore() { + if (!root.pl || !root.pl.continuation || root.loadingMore || root.moreError) + return; + root.loadingMore = true; + var token = root.pl.continuation; + Daemon.call("get_playlist_more", { token: token }) + .then((more) => { + if (!root.pl || root.pl.continuation !== token) + return; + root.pl = Object.assign({}, root.pl, { + items: root.pl.items.concat(more.items), + continuation: more.items.length ? more.continuation : undefined + }); + root.loadingMore = false; + }) + .catch(() => { root.moreError = true; root.loadingMore = false; }); + } + function maybeLoadMore() { + if (!root.pl || !root.pl.continuation || root.loadingMore || root.moreError) + return; + if (body.view.contentHeight <= 0) + return; + if (body.view.contentY + body.view.height > body.view.contentHeight - 600) + root.loadMore(); + } + function play(start) { + if (!root.pl) + return; + var at = start === null ? null : root.pl.items.indexOf(root.shown[start]); + Daemon.call("play_playlist", { + items: root.pl.items, start: at === -1 ? null : at, + sourceName: root.sourceName, continuation: root.pl.continuation + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function shuffle() { + if (!root.pl || !root.pl.items.length) + return; + Daemon.call("play_playlist", { + items: root.pl.items, start: null, sourceName: root.sourceName, + shuffle: true, continuation: root.pl.continuation + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + Component.onCompleted: root.ensureLoaded() + + Text { + anchors.centerIn: parent + visible: root.loading || root.errorMsg !== "" + text: root.loading ? "Loading your songs…" : root.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + TrackList { + id: body + anchors.fill: parent + visible: !root.loading && root.errorMsg === "" + items: root.shown + showHeader: true + showAlbum: true + showPlays: true + canAdd: true + source: root.sourceName + onActivated: (i) => root.play(i) + header: songsHeader + footer: songsFooter + Component.onCompleted: body.view.contentYChanged.connect(root.maybeLoadMore) + } + + Component { + id: songsHeader + Item { + width: body.view.width + implicitHeight: hc.implicitHeight + Style.sp(6) + ColumnLayout { + id: hc + width: parent.width + y: Style.sp(2) + spacing: Style.sp(3) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Pill { + label: "Play all"; icon: "play"; primary: true + enabled: !!(root.pl && root.pl.items.length) + onClicked: root.play(null) + } + Pill { + label: "Shuffle"; icon: "shuffle" + enabled: !!(root.pl && root.pl.items.length) + onClicked: root.shuffle() + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: Style.sp(52) + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: songFilter.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: songFilter + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: root.query + onTextChanged: root.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: songFilter.text.length === 0 + text: "Filter songs" + color: Tokens.inkFaint + font: songFilter.font + } + } + } + } + } + } + } + } + + Component { + id: songsFooter + Item { + width: body.view.width + implicitHeight: Style.sp(20) + ColumnLayout { + anchors.centerIn: parent + spacing: Style.sp(1) + Text { + Layout.alignment: Qt.AlignHCenter + visible: root.loadingMore + text: "Loading more…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Rectangle { + Layout.alignment: Qt.AlignHCenter + visible: root.moreError + implicitWidth: Style.sp(24); implicitHeight: Style.sp(9) + radius: Style.radius + color: tryHover.hovered ? Tokens.tint10 : "transparent" + border.width: 1; border.color: Tokens.line + Text { anchors.centerIn: parent; text: "Try again"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.sm } + HoverHandler { id: tryHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { root.moreError = false; root.loadMore(); } } + } + Text { + Layout.alignment: Qt.AlignHCenter + visible: !root.loading && !root.shown.length + text: root.query.trim() ? "No songs match." + : (root.youtubeLibrary ? "No songs in your library yet." + : "Nothing liked yet. The heart on a playing track saves it here, on this device.") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + } +} diff --git a/client/components/ListeningInsights.qml b/client/components/ListeningInsights.qml new file mode 100644 index 0000000..231d117 --- /dev/null +++ b/client/components/ListeningInsights.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// The library Insights tab, ported from ListeningInsights.svelte: on-demand local listening stats +// per period (no background polling, per the performance budget). listening_stats(period) once per +// selection; a request counter drops a stale period's answer. The summary numbers render as stat +// cells (tracked micro label + Fraunces value); the top artists / tracks sit under section rules. +Item { + id: root + + property string period: "week" + property var stats: null + property bool loading: true + property string errorMsg: "" + property int request: 0 + property bool started: false + + readonly property bool ready: !root.loading && root.errorMsg === "" && root.stats !== null + + readonly property var overview: root.stats ? [ + { label: "PLAYS", value: String(root.stats.plays) }, + { label: "KNOWN DURATION", value: root.fmtDuration(root.stats.knownDurationSeconds) } + ] : [] + + function ensureLoaded() { + if (root.started) + return; + root.started = true; + root.load("week"); + } + function load(p) { + root.period = p; + root.loading = true; + root.errorMsg = ""; + var seq = ++root.request; + Daemon.call("listening_stats", { period: p }) + .then((s) => { if (seq === root.request) { root.stats = s; root.loading = false; } }) + .catch((e) => { if (seq === root.request) { root.errorMsg = (e && e.message) ? e.message : String(e); root.loading = false; } }); + } + function fmtDuration(secs) { + var s = Math.max(0, Math.round(secs || 0)); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + return h > 0 ? (h + "h " + m + "m") : (m + "m"); + } + + Component.onCompleted: root.ensureLoaded() + + Flickable { + anchors.fill: parent + topMargin: Style.sp(2) + bottomMargin: Style.sp(20) + clip: true + contentWidth: width + contentHeight: col.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: col + width: parent.width + spacing: Style.sp(5) + + // period selector + Row { + spacing: Style.sp(2) + Repeater { + model: [{ k: "day", l: "Day" }, { k: "week", l: "Week" }, { k: "month", l: "Month" }] + delegate: Chip { + required property var modelData + text: modelData.l + active: root.period === modelData.k + onClicked: root.load(modelData.k) + } + } + } + + Text { + visible: root.loading || root.errorMsg !== "" + text: root.loading ? "Reading listening history…" : root.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + // overview — the summary stats as responsive stat cells + ColumnLayout { + Layout.fillWidth: true + visible: root.ready + spacing: Style.sp(3) + SectionHeading { Layout.fillWidth: true; title: "Overview" } + Flow { + Layout.fillWidth: true + spacing: Style.sp(3) + Repeater { + model: root.overview + delegate: Rectangle { + id: statCell + required property var modelData + implicitWidth: Math.max(Style.sp(44), cellCol.implicitWidth + Style.sp(8)) + implicitHeight: cellCol.implicitHeight + Style.sp(8) + radius: Style.radiusCard + color: Tokens.tint5 + border.width: 1 + border.color: Tokens.lineSoft + ColumnLayout { + id: cellCol + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Style.sp(4) + spacing: Style.sp(2) + Text { + text: statCell.modelData.label + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: statCell.modelData.value + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.xl + } + } + } + } + } + } + + // top artists + ColumnLayout { + Layout.fillWidth: true + visible: root.ready + spacing: Style.sp(2) + SectionHeading { Layout.fillWidth: true; title: "Top artists" } + Repeater { + model: (root.stats && root.stats.topArtists) ? root.stats.topArtists : [] + delegate: RowLayout { + id: artistRow + required property var modelData + required property int index + Layout.fillWidth: true + spacing: Style.sp(3) + Text { text: String(artistRow.index + 1).padStart(2, "0"); color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.sm } + Text { Layout.fillWidth: true; text: artistRow.modelData.name; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + Text { text: artistRow.modelData.plays + " plays"; color: Tokens.inkDim; font.family: Style.fontMono; font.pixelSize: Style.fs.sm } + } + } + Text { + visible: !!(root.stats && (!root.stats.topArtists || !root.stats.topArtists.length)) + text: "Play some music and this fills itself in." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + + // top tracks + ColumnLayout { + Layout.fillWidth: true + visible: root.ready + spacing: Style.sp(2) + SectionHeading { Layout.fillWidth: true; title: "Top tracks" } + Repeater { + model: (root.stats && root.stats.topTracks) ? root.stats.topTracks : [] + delegate: RowLayout { + id: trackRow + required property var modelData + required property int index + Layout.fillWidth: true + spacing: Style.sp(3) + Text { text: String(trackRow.index + 1).padStart(2, "0"); color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.sm } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { Layout.fillWidth: true; text: trackRow.modelData.title; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + Text { Layout.fillWidth: true; text: trackRow.modelData.artists; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; elide: Text.ElideRight } + } + Text { text: trackRow.modelData.plays + " plays"; color: Tokens.inkDim; font.family: Style.fontMono; font.pixelSize: Style.fs.sm } + } + } + Text { + visible: !!(root.stats && (!root.stats.topTracks || !root.stats.topTracks.length)) + text: "Nothing recorded for this period yet." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + } +} diff --git a/client/components/LocalMusic.qml b/client/components/LocalMusic.qml new file mode 100644 index 0000000..4b3f43b --- /dev/null +++ b/client/components/LocalMusic.qml @@ -0,0 +1,278 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Ryoku.Ui.Singletons +import "../" + +// The library Local tab, ported from LocalMusic.svelte: files on disk, scanned from the watched +// folders. get_local_library on open (cheap rescan); add/remove a folder over the socket with an +// explicit path (the daemon dropped the Tauri dialog), so the picker here is zenity — the same +// choice Task 7's settings pickers make. Sub-tabs Albums / Artists / Songs, filtered in memory. +Item { + id: root + + property var lib: ({ folders: [], albums: [], artists: [], songs: [] }) + property bool loading: true + property string errorMsg: "" + property bool started: false + property string view: "songs" + property string query: "" + + readonly property var songs: { + var s = (root.lib && root.lib.songs) ? root.lib.songs : []; + var q = root.query.trim().toLowerCase(); + if (!q) + return s; + return s.filter((t) => ( + (t.title && t.title.toLowerCase().indexOf(q) >= 0) + || (t.artists && t.artists.toLowerCase().indexOf(q) >= 0) + || (t.album && t.album.toLowerCase().indexOf(q) >= 0))); + } + readonly property var cards: { + var arr = root.view === "albums" ? (root.lib.albums || []) : (root.lib.artists || []); + var q = root.query.trim().toLowerCase(); + if (!q) + return arr; + return arr.filter((c) => (c.title && c.title.toLowerCase().indexOf(q) >= 0) + || (c.subtitle && c.subtitle.toLowerCase().indexOf(q) >= 0)); + } + + function ensureLoaded() { + if (root.started) + return; + root.started = true; + root.scan(); + } + function scan() { + root.loading = true; + root.errorMsg = ""; + Daemon.call("get_local_library") + .then((l) => { root.lib = l || root.lib; root.loading = false; }) + .catch((e) => { root.errorMsg = (e && e.message) ? e.message : String(e); root.loading = false; }); + } + function addFolder(path) { + if (!path) + return; + Daemon.call("add_local_folder", { path: path }) + .then((l) => { if (l) root.lib = l; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not add folder", "error")); + } + function removeFolder(path) { + Daemon.call("remove_local_folder", { path: path }) + .then((l) => { root.lib = l || root.lib; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not remove folder", "error")); + } + function playSongs(start) { + if (!root.songs.length) + return; + var at = start === null ? null : (root.lib.songs || []).indexOf(root.songs[start]); + Daemon.call("play_playlist", { + items: root.lib.songs, start: at === -1 ? null : at, sourceName: "Local music" + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + Component.onCompleted: root.ensureLoaded() + + // zenity directory picker → add_local_folder + Process { + id: picker + command: ["zenity", "--file-selection", "--directory", "--title=Choose a music folder"] + stdout: StdioCollector { + id: pickerOut + onStreamFinished: { var p = pickerOut.text.trim(); if (p) root.addFolder(p); } + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Style.sp(4) + + // folders group + SectionHeading { Layout.fillWidth: true; title: "Folders" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Flickable { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + contentWidth: folderRow.implicitWidth + contentHeight: folderRow.implicitHeight + flickableDirection: Flickable.HorizontalFlick + clip: true + Row { + id: folderRow + spacing: Style.sp(2) + Repeater { + model: (root.lib && root.lib.folders) ? root.lib.folders : [] + delegate: Rectangle { + id: folderChip + required property var modelData + implicitWidth: fLabel.implicitWidth + Style.sp(10) + implicitHeight: Style.sp(8) + radius: Style.radius + color: Tokens.tint5 + border.width: 1 + border.color: Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(1) + spacing: Style.sp(1) + Text { + id: fLabel + Layout.maximumWidth: Style.sp(60) + text: folderChip.modelData + color: Tokens.inkDim + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + elide: Text.ElideLeft + } + IconButton { + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(6) + onClicked: root.removeFolder(folderChip.modelData) + } + } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !(root.lib && root.lib.folders && root.lib.folders.length) + text: "No folders yet. Add the one your music sits in." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + Pill { label: "Add folder"; icon: "add"; onClicked: picker.running = true } + Pill { label: "Rescan"; icon: "on-repeat"; onClicked: root.scan() } + } + + // sub-tabs + filter + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Repeater { + model: [ + { k: "albums", l: "Albums" }, + { k: "artists", l: "Artists" }, + { k: "songs", l: "Songs" } + ] + delegate: Chip { + required property var modelData + text: modelData.l + active: root.view === modelData.k + onClicked: root.view = modelData.k + } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: Style.sp(52) + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: localFilter.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: localFilter + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: root.query + onTextChanged: root.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: localFilter.text.length === 0 + text: "Filter" + color: Tokens.inkFaint + font: localFilter.font + } + } + } + } + } + + Hairline { Layout.fillWidth: true } + + // content + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Text { + anchors.centerIn: parent + visible: root.loading || root.errorMsg !== "" + text: root.loading ? "Scanning local music…" : root.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + // songs sub-tab + TrackList { + id: localBody + anchors.fill: parent + visible: !root.loading && root.view === "songs" + items: root.songs + showHeader: true + showAlbum: true + canAdd: false + source: "Local music" + onActivated: (i) => root.playSongs(i) + header: localSongsHeader + } + + // albums / artists sub-tab + CardGrid { + anchors.fill: parent + visible: !root.loading && root.view !== "songs" + pad: 0 + model: root.cards + emptyText: "Nothing on this device matches." + } + } + } + + Component { + id: localSongsHeader + Item { + width: localBody.view.width + implicitHeight: Style.sp(12) + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: Style.sp(2) + spacing: Style.sp(3) + Pill { + label: "Play all"; icon: "play"; primary: true + enabled: root.songs.length > 0 + onClicked: root.playSongs(null) + } + Pill { + label: "Shuffle"; icon: "shuffle" + enabled: root.songs.length > 0 + onClicked: { + if (!root.songs.length) return; + Daemon.call("play_playlist", { items: root.lib.songs, start: null, sourceName: "Local music", shuffle: true }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + } + Item { Layout.fillWidth: true } + } + } + } +} diff --git a/client/components/MediaCard.qml b/client/components/MediaCard.qml new file mode 100644 index 0000000..41dc75a --- /dev/null +++ b/client/components/MediaCard.qml @@ -0,0 +1,148 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../lib/ids.js" as Ids +import "../lib/browse.js" as Browse + +// A shelf / grid card (spec section 4): 168 px square artwork (a circle for an artist) at +// radiusCard, a title in md and a subtitle in sm. Hover lifts the whole card two pixels and rings +// the art with a lineStrong border, animated over Style.motion.snap. A primary click opens the item +// (a song plays, a collection routes to its page); the hover play button plays the whole thing +// without leaving the page. +Item { + id: root + + property var item: null + property int cardWidth: Style.cardW + readonly property bool round: root.item && root.item.kind === "artist" + + width: cardWidth + implicitHeight: col.implicitHeight + + // active hover lift (transform, so it never disturbs layout) + transform: Translate { + y: cardHover.hovered ? -2 : 0 + Behavior on y { NumberAnimation { duration: Style.motion.snap; easing.type: Easing.OutQuad } } + } + + function open() { + if (!root.item) + return; + if (root.item.kind === "song") + Playback.play(Browse.asSong(root.item)); + else + Router.push(root.item.kind, { id: root.item.id, title: root.item.title }); + } + + function playNow() { + var it = root.item; + if (!it) + return; + if (it.kind === "song") { + Playback.play(Browse.asSong(it)); + return; + } + if (it.kind === "album") { + Daemon.call("get_album", { id: it.id }) + .then((a) => Daemon.call("play_playlist", { items: a.items, sourceId: a.playlistId, sourceName: it.title })) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } else { + Daemon.call("get_playlist", { id: it.id }) + .then((p) => Daemon.call("play_playlist", { + items: p.items, + sourceId: Ids.isSmartPlaylistId(it.id) ? undefined : it.id, + sourceName: it.title, + continuation: p.continuation + })) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.open() + } + + ColumnLayout { + id: col + width: parent.width + spacing: Style.sp(2) + + Item { + Layout.fillWidth: true + implicitHeight: width + + Artwork { + id: art + anchors.fill: parent + url: root.item && root.item.thumbnail ? root.item.thumbnail : "" + px: root.cardWidth + round: root.round + cornerRadius: root.round ? art.width / 2 : Style.radiusCard + placeholderIcon: root.round ? "user" + : (root.item && Ids.isOnRepeatId(root.item.id)) ? "on-repeat" : "music" + } + + // hover ring + Rectangle { + anchors.fill: parent + visible: cardHover.hovered + radius: root.round ? width / 2 : Style.radiusCard + color: "transparent" + border.width: 1 + border.color: Tokens.lineStrong + } + + // hover play (everything but an artist) + Rectangle { + visible: !root.round && cardHover.hovered + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Style.sp(2) + width: Style.sp(9) + height: width + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + Icon { + anchors.centerIn: parent + name: "play" + size: Style.fs.md + color: Tokens.ink + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.playNow() + } + } + } + + Text { + Layout.fillWidth: true + horizontalAlignment: root.round ? Text.AlignHCenter : Text.AlignLeft + text: root.item ? root.item.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: !!(root.item && root.item.subtitle) + horizontalAlignment: root.round ? Text.AlignHCenter : Text.AlignLeft + text: root.item && root.item.subtitle ? root.item.subtitle : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + HoverHandler { id: cardHover } +} diff --git a/client/components/Menu.qml b/client/components/Menu.qml new file mode 100644 index 0000000..55ce4c4 --- /dev/null +++ b/client/components/Menu.qml @@ -0,0 +1,180 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Ryoku.Ui.Singletons +import "../" +import "../lib/ids.js" as Ids + +// The track ⋯ options menu, ported from TrackMenu.svelte. One shared instance per list surface, +// opened at the ⋯ trigger or a right-click on the row (openAt in this item's own space, so the +// menu is never clipped by the list's own clip rect). The queue actions are universal; go-to +// artist/album, like, radio, share and the YouTube-playlist writes hide for local files and radio +// stations, which carry no YouTube identity. "Add to playlist" is self-served by the embedded +// picker; "Remove" is context-specific, so it calls back through removeRequested(). +Item { + id: root + + anchors.fill: parent + z: 150 + + property var song: null + // A caller-supplied item list ([{icon,label,act,danger}]) for page-level menus (album/artist + // headers). When set it replaces the song-derived track items. + property var customItems: null + // "Add to playlist" shows when true (a page that can't add — a local list — passes false). + property bool canAdd: true + // "Remove from …" shows when true; the host owns what removal means for its list. + property bool canRemove: false + property string removeLabel: "Remove from playlist" + // The "from" heading a queued block wears in the queue panel. + property string source: "" + + signal removeRequested() + + readonly property bool open: panel.visible + readonly property bool noYouTubeTrack: !!root.song + && (Ids.isLocalId(root.song.video_id) || Ids.isRadioId(root.song.video_id)) + readonly property string rated: { + if (root.song && Playback.now && Playback.now.videoId === root.song.video_id) + return Playback.rating; + return (root.song && root.song.rating) ? root.song.rating : "indifferent"; + } + + function openAt(sx, sy) { + if (!root.song && !root.customItems) + return; + panel.visible = true; + var w = panel.width; + var h = panel.height; + panel.x = Math.max(Style.sp(2), Math.min(sx, root.width - w - Style.sp(2))); + panel.y = (sy + h > root.height - Style.sp(2)) ? Math.max(Style.sp(2), sy - h) : sy; + } + function close() { panel.visible = false; } + + function toast(msg, kind) { Playback.toast(msg, kind); } + + function enqueue(next) { + Daemon.call(next ? "play_next" : "add_to_queue", + { items: [root.song], from: root.source ? root.source : null }) + .then(() => root.toast(next ? "Playing next" : "Added to queue", "success")) + .catch((e) => root.toast((e && e.message) ? e.message : "Could not queue", "error")); + } + function startRadio() { + root.toast("Starting radio…", "info"); + Daemon.call("start_radio", { kind: "song", id: root.song.video_id, name: root.song.title }) + .catch((e) => root.toast((e && e.message) ? e.message : "Could not start radio", "error")); + } + function toggleLike() { + var next = root.rated === "like" ? "indifferent" : "like"; + Daemon.call("rate", { videoId: root.song.video_id, rating: next }) + .catch((e) => root.toast((e && e.message) ? e.message : "Could not rate", "error")); + } + function share() { + var id = root.song.video_id; + var url = "https://music.youtube.com/watch?v=" + encodeURIComponent(id); + Quickshell.execDetached(["sh", "-c", "printf %s \"$1\" | wl-copy", "sh", url]); + root.toast("Link copied", "success"); + } + + // The item list for the current song, in TrackMenu.svelte order. + function buildItems() { + if (root.customItems) + return root.customItems; + var s = root.song; + if (!s) + return []; + var out = []; + out.push({ icon: "arrow-up", label: "Play next", danger: false, act: () => root.enqueue(true) }); + out.push({ icon: "queue", label: "Add to queue", danger: false, act: () => root.enqueue(false) }); + if (!root.noYouTubeTrack) + out.push({ icon: "radio", label: "Start radio", danger: false, act: () => root.startRadio() }); + if (!root.noYouTubeTrack) + out.push({ icon: "heart", label: root.rated === "like" ? "Remove from Liked Songs" : "Save to Liked Songs", + danger: false, act: () => root.toggleLike() }); + if (s.artist_id) + out.push({ icon: "artists", label: "Go to artist", danger: false, + act: () => Router.push("artist", { id: s.artist_id }) }); + if (s.album_id && !root.noYouTubeTrack) + out.push({ icon: "cd", label: "Go to album", danger: false, + act: () => Router.push("album", { id: s.album_id, title: s.album }) }); + if (root.canAdd && !root.noYouTubeTrack) + out.push({ icon: "add", label: "Add to playlist", danger: false, + act: () => picker.openWith([s]) }); + if (!root.noYouTubeTrack) + out.push({ icon: "link", label: "Share", danger: false, act: () => root.share() }); + if (root.canRemove) + out.push({ icon: "close", label: root.removeLabel, danger: true, act: () => root.removeRequested() }); + return out; + } + + // dismiss layer + MouseArea { + anchors.fill: parent + visible: root.open + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: root.close() + } + + Rectangle { + id: panel + visible: false + width: Style.sp(54) + implicitHeight: col.implicitHeight + Style.sp(2) + height: implicitHeight + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + + ColumnLayout { + id: col + anchors.fill: parent + anchors.margins: Style.sp(1) + spacing: 0 + + Repeater { + model: root.open ? root.buildItems() : [] + delegate: Rectangle { + id: item + required property var modelData + Layout.fillWidth: true + implicitHeight: Style.sp(8) + radius: Style.radius + color: itemHover.hovered ? (item.modelData.danger ? Tokens.tint10 : Tokens.tint5) : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { + name: item.modelData.icon + size: Style.fs.md + color: item.modelData.danger ? Style.alert : Tokens.inkDim + } + Text { + Layout.fillWidth: true + text: item.modelData.label + color: item.modelData.danger ? Style.alert : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + elide: Text.ElideRight + } + } + HoverHandler { id: itemHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + var act = item.modelData.act; + root.close(); + act(); + } + } + } + } + } + } + + AddToPlaylist { id: picker } +} diff --git a/client/components/NowPlayingCard.qml b/client/components/NowPlayingCard.qml new file mode 100644 index 0000000..dfcf775 --- /dev/null +++ b/client/components/NowPlayingCard.qml @@ -0,0 +1,284 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui as RU +import Ryoku.Ui.Singletons +import "../" +import "../lib/ids.js" as Ids + +// The Home hero's Now Playing card (spec "Home hero"): a ~550 x 125 paper card with a square, full +// height artwork on the left (the shared Spectrum ramped over its foot while playing) and, on the +// right, the PLAYBACK / STREAM readout, the track title in Fraunces, the artist, three mono stat +// cells that surface Ryotunes' Sound effects (RATIO / SPEED / TEMPO), a tiny prev / play / next +// transport at the foot and a QUEUE pill that opens the Now Playing stage's queue tab. It only +// exists while something plays; HomePage hides it (and drops to a single column) on a narrow page, +// so it can never overlap the greeting column. +Rectangle { + id: root + + // Asks the App to open the Now Playing stage on its queue tab (App wires + // Playback.nowPlayingRequested -> npOpenTab, so this reaches the stage from anywhere). + signal openQueue() + + implicitWidth: Style.sp(138) // ~550 + implicitHeight: cardRow.implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + clip: true + + readonly property var now: Playback.now + readonly property bool live: !!now && !Playback.paused + readonly property string sourceLabel: !now ? "READY" + : Ids.isLocalId(now.videoId) ? "LOCAL" + : (now.streamClient === "cache" ? "CACHE" : "STREAM") + + // The three figures showcase the Sound chain (spec section 7): the stereo width as a ratio, the + // tempo multiplier, and the pitch shift in semitones — the state normal players never expose. + readonly property string ratioLabel: Math.round(Playback.audioFx.width * 100) + "%" + readonly property string speedLabel: Playback.audioFx.speed.toFixed(2) + "×" + readonly property string tempoLabel: { + var s = Math.round(Playback.audioFx.semitones); + return (s > 0 ? "+" : "") + s + " st"; + } + + // A SoundCloud track shows its waveform and the plays / likes / genre line in place of the + // Sound-effect figures, the Orange look on Home. + readonly property bool scNow: !!root.now && String(root.now.videoId).startsWith("sc:") + readonly property string scMeta: { + var n = root.now; + if (!n) + return ""; + var parts = []; + var p = Style.fmtCount(n.plays); + if (p) parts.push(p + " plays"); + var l = Style.fmtCount(n.likes); + if (l) parts.push(l + " likes"); + if (n.genre) parts.push(String(n.genre)); + return parts.join(" \u00b7 "); + } + + // The card claims the spectrum feed only while it is on screen (Spectrum runs cava only when a + // claim is live AND Style.live holds — something is playing). + function claim(on) { Spectrum.claim("npcard", on); } + Component.onCompleted: root.claim(root.visible) + Component.onDestruction: root.claim(false) + onVisibleChanged: root.claim(root.visible) + + RowLayout { + id: cardRow + anchors.fill: parent + spacing: 0 + + // --- art (square, full height) + spectrum foot ------------------------------------- + Item { + id: art + Layout.fillHeight: true + Layout.preferredWidth: root.height + clip: true + + Rectangle { anchors.fill: parent; color: Tokens.paperLift } + + Image { + id: cover + anchors.fill: parent + source: root.now && root.now.thumbnail ? Style.thumb(root.now.thumbnail, 320) : "" + sourceSize: Qt.size(320, 320) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Style.motion.slow } } + } + + // idle placeholder: a single note (the card is only shown while playing, so this only + // flashes before the first cover decodes) + Icon { + anchors.centerIn: parent + visible: cover.status !== Image.Ready + name: "music" + size: Style.sp(6) + color: Tokens.inkMuted + } + + // bottom wash so the bars read over any artwork + Rectangle { + anchors.fill: parent + visible: cover.status === Image.Ready + gradient: Gradient { + GradientStop { position: 0.45; color: "transparent" } + GradientStop { position: 1.0; color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.75) } + } + } + + // the spectrum, ramped from the cover accent into ink + RU.SpectrumField { + anchors.fill: parent + levels: Spectrum.levels + energy: Spectrum.energy + fade: root.live ? 1 : 0 + ramp: [Style.accent, Tokens.ink] + style: "bars" + boxX: 0; boxY: 0.52; boxW: 1; boxH: 0.48 + grow: "up" + thickness: 0.5 + glow: 0.45 + Behavior on fade { NumberAnimation { duration: Style.motion.slow } } + } + + // LIVE tag, its dot breathing under ambient motion + Row { + x: Style.sp(2); y: Style.sp(2) + spacing: Style.sp(1) + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Style.sp(1.5); height: width; radius: height / 2 + color: Style.accent + opacity: 0.9 + SequentialAnimation on opacity { + running: Style.ambient + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { from: 0.9; to: 0.3; duration: 500; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.3; to: 0.9; duration: 500; easing.type: Easing.InOutSine } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "LIVE" + color: Qt.rgba(Tokens.ink.r, Tokens.ink.g, Tokens.ink.b, 0.8) + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.openQueue() + } + Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: Tokens.line } + } + + // --- readout, title, figures, transport -------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(1.5) + Layout.bottomMargin: Style.sp(1.5) + spacing: Style.sp(0.5) + + // PLAYBACK ——— STREAM + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { text: "PLAYBACK"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Tokens.lineSoft } + Text { text: root.sourceLabel; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + } + + Text { + Layout.fillWidth: true + Layout.topMargin: Style.sp(0.5) + text: root.now ? root.now.title : "" + color: titleHover.hovered ? Tokens.inkDim : Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.xl + elide: Text.ElideRight + HoverHandler { id: titleHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.openQueue() } + } + Text { + Layout.fillWidth: true + text: root.now ? root.now.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: root.scNow && root.scMeta !== "" + text: root.scMeta + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + + // RATIO / SPEED / TEMPO (a SoundCloud track shows its waveform instead) + RowLayout { + visible: !root.scNow + Layout.fillWidth: true + spacing: Style.sp(2) + Repeater { + model: [ + { k: "RATIO", v: root.ratioLabel }, + { k: "SPEED", v: root.speedLabel }, + { k: "TEMPO", v: root.tempoLabel } + ] + delegate: ColumnLayout { + id: cell + required property var modelData + Layout.fillWidth: true + spacing: 1 + Text { text: cell.modelData.k; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Text { text: cell.modelData.v; color: Tokens.inkDim; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; font.weight: Font.Medium } + } + } + } + + // SoundCloud waveform, in the figures' place + Waveform { + visible: root.scNow + Layout.fillWidth: true + Layout.preferredHeight: 56 + samples: Playback.waveform || [] + } + + // transport (prev / play / next) + queue pill + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(1) + IconButton { icon: "previous"; iconSize: Style.fs.md; diameter: Style.sp(7); tip: "Previous"; onClicked: Playback.prev() } + IconButton { icon: Playback.paused ? "play" : "pause"; iconSize: Style.fs.md; diameter: Style.sp(7); primary: true; tip: Playback.paused ? "Play" : "Pause"; onClicked: Playback.togglePause() } + IconButton { icon: "next"; iconSize: Style.fs.md; diameter: Style.sp(7); tip: "Next"; onClicked: Playback.next() } + Item { Layout.fillWidth: true } + + // QUEUE pill + Rectangle { + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.sp(7) + implicitWidth: pillRow.implicitWidth + Style.sp(5) + radius: height / 2 + color: pillHover.hovered ? Tokens.tint10 : "transparent" + border.width: 1 + border.color: pillHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + Row { + id: pillRow + anchors.centerIn: parent + spacing: Style.sp(1.5) + Icon { anchors.verticalCenter: parent.verticalCenter; name: "queue"; size: Style.fs.sm; color: Tokens.inkMuted } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "QUEUE" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + HoverHandler { id: pillHover; cursorShape: Qt.PointingHandCursor } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.openQueue() } + } + } + } + } +} diff --git a/client/components/PageHero.qml b/client/components/PageHero.qml new file mode 100644 index 0000000..73991c4 --- /dev/null +++ b/client/components/PageHero.qml @@ -0,0 +1,114 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// A page's head, Sonora's fixed art-plus-title block in Ryoku's type: 168 px art (a circle for +// artists), then the tracked eyebrow (ALBUM / PLAYLIST / ARTIST), the Fraunces title, a meta line +// and the action row (Play, like, more). Flat paper behind it: the print texture rides the chrome +// (the nav rail), never the content (docs/ui-ux.md). +Item { + id: root + + property string eyebrow: "" + property string title: "" + property string meta: "" + property string art: "" + property bool round: false + property string placeholderIcon: "music" + property string primaryLabel: "Play" + property bool primaryPlaying: false + property bool likeable: false + property bool liked: false + property bool showMore: true + signal primary() + signal like() + signal more() + + implicitHeight: Math.max(Style.heroArt, copy.implicitHeight) + Style.sp(4) + + + RowLayout { + anchors.fill: parent + anchors.bottomMargin: Style.sp(4) + spacing: Style.sp(6) + + Artwork { + Layout.preferredWidth: Style.heroArt + Layout.preferredHeight: Style.heroArt + Layout.alignment: Qt.AlignVCenter + url: root.art + px: Style.heroArt + round: root.round + placeholderIcon: root.placeholderIcon + cornerRadius: root.round ? Style.heroArt / 2 : Style.radiusCard + } + + ColumnLayout { + id: copy + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(2) + + Text { + visible: root.eyebrow !== "" + text: root.eyebrow.toUpperCase() + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + text: root.title + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + lineHeight: 1.05 + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Text { + visible: root.meta !== "" + Layout.fillWidth: true + text: root.meta + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + RowLayout { + Layout.topMargin: Style.sp(2) + spacing: Style.sp(2) + Btn { + primary: true + icon: root.primaryPlaying ? "pause" : "play" + text: root.primaryPlaying ? "Pause" : root.primaryLabel + onClicked: root.primary() + } + IconButton { + visible: root.likeable + icon: "heart" + iconSize: Style.fs.md + diameter: Style.ctlH + outlined: true + active: root.liked + iconColor: root.liked ? Style.accent : Tokens.inkMuted + tip: root.liked ? "Remove from library" : "Save to library" + onClicked: root.like() + } + IconButton { + visible: root.showMore + icon: "more" + iconSize: Style.fs.md + diameter: Style.ctlH + outlined: true + tip: "More" + onClicked: root.more() + } + } + } + } +} diff --git a/client/components/Pill.qml b/client/components/Pill.qml new file mode 100644 index 0000000..5600d7d --- /dev/null +++ b/client/components/Pill.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// A labelled pill action (Play / Shuffle / Save / Subscribe), the rounded header control the album, +// artist and playlist pages share. `primary` is the one filled call-to-action (drawn on the bone +// plate); `active` pins the accent outline of a toggled state (In library / Subscribed). +Item { + id: root + + property string label: "" + property string icon: "" + property bool primary: false + property bool active: false + signal clicked() + + implicitHeight: Style.sp(10) + implicitWidth: row.implicitWidth + Style.sp(10) + opacity: enabled ? 1 : 0.5 + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: root.primary ? Tokens.bone + : ma.pressed ? Tokens.tint16 + : hover.hovered ? Tokens.tint5 + : "transparent" + border.width: root.primary ? 0 : 1 + border.color: root.active ? Tokens.sun : Tokens.line + } + + RowLayout { + id: row + anchors.centerIn: parent + spacing: Style.sp(2) + Icon { + visible: root.icon !== "" + name: root.icon + size: Style.fs.md + color: root.primary ? Tokens.inkOnBone : root.active ? Tokens.sun : Tokens.ink + } + Text { + text: root.label + color: root.primary ? Tokens.inkOnBone : root.active ? Tokens.sun : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.DemiBold + } + } + + HoverHandler { id: hover; enabled: root.enabled } + MouseArea { + id: ma + anchors.fill: parent + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/client/components/ProviderGlow.qml b/client/components/ProviderGlow.qml new file mode 100644 index 0000000..e2bd238 --- /dev/null +++ b/client/components/ProviderGlow.qml @@ -0,0 +1,34 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// The provider's light: a red (YouTube Music) or green (Spotify) radial glow at `focusX/Y`, the one +// clear tell of which catalogue is on, tweened between the two on a switch. One static Canvas, +// repainted only on resize or a colour step; it sits above the paper glass so it reads. +Canvas { + id: root + + property real focusX: 0.82 + property real focusY: 0.1 + property real strength: 0.28 + property real radius: 0.6 + + property color glowColor: Style.providerColor + Behavior on glowColor { ColorAnimation { duration: Tokens.durSlowEffects } } + onGlowColorChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + var c = getContext("2d"); + c.clearRect(0, 0, width, height); + var fx = width * root.focusX, fy = height * root.focusY; + var pc = root.glowColor; + var g = c.createRadialGradient(fx, fy, 0, fx, fy, Math.max(width, height) * root.radius); + g.addColorStop(0, Qt.rgba(pc.r, pc.g, pc.b, root.strength)); + g.addColorStop(0.35, Qt.rgba(pc.r, pc.g, pc.b, root.strength * 0.4)); + g.addColorStop(1, Qt.rgba(pc.r, pc.g, pc.b, 0)); + c.fillStyle = g; + c.fillRect(0, 0, width, height); + } +} diff --git a/client/components/SectionHeading.qml b/client/components/SectionHeading.qml new file mode 100644 index 0000000..7e01639 --- /dev/null +++ b/client/components/SectionHeading.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// A shelf / section header: the title in lg 500, a hairline rule filling the dead space, an optional +// kana `mark` at the right when the decor is rich, and an optional "See all" affordance. The kind +// icon and the "//" mark of the first pass are gone — one editorial heading, per spec section 4. +RowLayout { + id: root + + property string title: "" + // A kana glyph drawn at the far right when Style.decorRich (a section seal); "" hides it. + property string mark: "" + property bool more: false + signal moreClicked() + + spacing: Style.sp(3) + + Text { + Layout.maximumWidth: Style.sp(100) + text: root.title + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.Medium + elide: Text.ElideRight + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.alignment: Qt.AlignVCenter + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Tokens.line } + GradientStop { position: 0.7; color: Tokens.lineSoft } + GradientStop { position: 1.0; color: "transparent" } + } + } + + // "See all" + Item { + visible: root.more + implicitWidth: moreRow.implicitWidth + implicitHeight: moreRow.implicitHeight + Layout.alignment: Qt.AlignVCenter + Row { + id: moreRow + spacing: Style.sp(1) + Text { + anchors.verticalCenter: parent.verticalCenter + text: "SEE ALL" + color: seeHover.hovered ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Icon { + anchors.verticalCenter: parent.verticalCenter + name: "arrow-right" + size: Style.fs.sm + color: seeHover.hovered ? Tokens.ink : Tokens.inkMuted + } + } + HoverHandler { id: seeHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.moreClicked() } + } + + // kana seal (rich decor only) + Text { + visible: Style.decorRich && root.mark !== "" + Layout.alignment: Qt.AlignVCenter + text: root.mark + color: Tokens.inkFaint + font.family: Tokens.jp + font.pixelSize: Style.fs.sm + } +} diff --git a/client/components/Shelf.qml b/client/components/Shelf.qml new file mode 100644 index 0000000..0b39bd6 --- /dev/null +++ b/client/components/Shelf.qml @@ -0,0 +1,74 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui as RU +import "../" + +// A shelf (spec section 4): a SectionHeading over a horizontally flicking row of MediaCards, gap 16, +// each card revealed with a Ryoku.Ui.Entrance stagger. Fed either a feed `section` +// ({ title, items, moreBrowseId, moreParams }) or explicit `title`/`items`. A section with a +// moreBrowseId routes "See all" into its list page; otherwise the host handles `moreClicked`. +ColumnLayout { + id: root + + property var section: null + property string title: (section && section.title) ? section.title : "" + property var items: (section && section.items) ? section.items : [] + property bool more: !!(section && section.moreBrowseId) + property string mark: "" + signal moreClicked() + + spacing: Style.sp(4) + + function seeAll() { + if (root.section && root.section.moreBrowseId) + Router.push("list", { + id: root.section.moreBrowseId, + title: root.section.title, + params: root.section.moreParams + }); + else + root.moreClicked(); + } + + SectionHeading { + Layout.fillWidth: true + title: root.title + mark: root.mark + more: root.more + onMoreClicked: root.seeAll() + } + + Flickable { + Layout.fillWidth: true + implicitHeight: rail.implicitHeight + contentWidth: rail.implicitWidth + contentHeight: rail.implicitHeight + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true + + Row { + id: rail + spacing: Style.sp(4) + Repeater { + model: root.items + delegate: Item { + id: cell + required property var modelData + required property int index + implicitWidth: entrance.implicitWidth + implicitHeight: entrance.implicitHeight + RU.Entrance { + id: entrance + index: cell.index + MediaCard { + item: cell.modelData + cardWidth: Style.cardW + } + } + } + } + } + } +} diff --git a/client/components/ShortcutPicker.qml b/client/components/ShortcutPicker.qml new file mode 100644 index 0000000..7fc0c79 --- /dev/null +++ b/client/components/ShortcutPicker.qml @@ -0,0 +1,691 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Ryoku.Ui.Singletons +import "../" +import "../lib/personal.js" as P +import "../lib/playlist-links.js" as Links + +// The Home "Add shortcut" picker. Instead of sending the user off to search, this raises a modal +// over Home with three ways to pin a shortcut: the recents/library the user already has, a title +// filter that falls through to a daemon search, and a paste-a-YouTube-playlist-link field for the +// playlists that only exist as a URL (not in search, not in the library). A pasted link is resolved +// to a `VL…` browse id (lib/playlist-links.js) and previewed through `get_playlist` — never by +// fetching the URL — so the tile carries the playlist's real title and cover and reopens/plays it. +// +// Public surface (the parent anchors it over the Home page bounds): `opened`, `open()`, `closed()`. +// It stays instantiated and hides itself when closed. Every async reply is guarded by a sequence +// token and the open flag, so a stale search or link fetch that lands after a re-query, a close, or +// a reopen is dropped. +Item { + id: root + + anchors.fill: parent + visible: root.opened + z: 250 + + property bool opened: false + signal closed() + + // The sibling page content to snapshot-blur behind the card (spec §9); unset → scrim only. + property Item blurSource: null + + // The filter box, and the daemon results it falls through to. + property string query: "" + property var library: [] + property bool libLoaded: false + property var remote: [] + property bool searching: false + property int searchSeq: 0 + property string searchError: "" + property int librarySeq: 0 + + // The paste-a-link field and its previewed target. + property string linkInput: "" + property var linkPreview: null + property string linkError: "" + property bool linkLoading: false + property int linkSeq: 0 + + // The grid is a fixed size; a suggestion never evicts a hand-added tile, so at capacity the + // picker refuses with feedback rather than silently dropping the stalest shortcut. + readonly property int capacity: P.MAX_PICKS || 18 + readonly property bool full: Personal.picks.length >= root.capacity + + // Recents (newest first, already minus current shortcuts) plus the user's library playlists, + // deduped — the offline-friendly candidates shown before a search runs. + readonly property var recents: root.opened ? Personal.recent(40) : [] + readonly property var localCandidates: { + var out = []; + var seen = ({}); + var add = function (it) { + if (!it || !it.id || seen[it.id]) + return; + seen[it.id] = true; + out.push(it); + }; + var r = root.recents; + for (var i = 0; i < r.length; i++) + add(r[i]); + for (var j = 0; j < root.library.length; j++) + add(root.library[j]); + return out; + } + + // What the list shows: with no query, the local candidates; with one, local title matches first + // then the daemon results, deduped and always minus what is already a shortcut. + readonly property var candidates: { + var q = root.query.trim().toLowerCase(); + var picks = Personal.picks; + var isPick = function (id) { + for (var k = 0; k < picks.length; k++) + if (picks[k].id === id) + return true; + return false; + }; + var out = []; + var seen = ({}); + var add = function (it) { + if (!it || !it.id || seen[it.id] || isPick(it.id)) + return; + seen[it.id] = true; + out.push(it); + }; + var local = root.localCandidates; + for (var i = 0; i < local.length; i++) { + var it = local[i]; + if (!q || (String(it.title || "").toLowerCase().indexOf(q) >= 0) + || (String(it.subtitle || "").toLowerCase().indexOf(q) >= 0)) + add(it); + } + if (q) { + for (var j = 0; j < root.remote.length; j++) + add(root.remote[j]); + } + return out.slice(0, 40); + } + + function open() { + root.query = ""; + root.remote = []; + root.searching = false; + root.searchSeq++; + root.linkInput = ""; + root.linkPreview = null; + root.linkError = ""; + root.linkLoading = false; + root.linkSeq++; + root.opened = true; + if (root.blurSource) + snap.scheduleUpdate(); + root.loadLibrary(); + Qt.callLater(function () { filterField.forceActiveFocus(); }); + } + + function close() { + // Bump the guards so any in-flight reply that lands after close is ignored. + root.searchSeq++; + root.linkSeq++; + root.opened = false; + root.librarySeq++; + searchTimer.stop(); + linkTimer.stop(); + root.closed(); + } + + function loadLibrary() { + var seq = ++root.librarySeq; + var provider = Playback.provider; + root.library = []; + Daemon.call("get_library") + .then(function (items) { + if (seq !== root.librarySeq || !root.opened || provider !== Playback.provider) + return; + root.library = (items || []).filter(function (i) { return i && i.id && i.kind; }); + }) + .catch(function () { + if (seq === root.librarySeq && root.opened) + root.searchError = "Your library could not be loaded. Search or paste a public playlist link."; + }); + } + + function runSearch() { + if (!root.opened) + return; + root.searchError = ""; + var q = root.query.trim().replace(/\s+/g, " "); + var seq = ++root.searchSeq; + var prov = Playback.provider; + if (q.length < 2) { + root.remote = []; + root.searching = false; + return; + } + root.searching = true; + Daemon.call("search_all", { query: q }) + .then(function (res) { + if (seq !== root.searchSeq || !root.opened || Playback.provider !== prov) + return; + var flat = []; + var pushAll = function (arr) { + if (!arr) + return; + for (var i = 0; i < arr.length; i++) { + var it = arr[i]; + if (it && it.kind === "song" && it.isVideo) + continue; + if (it && it.id) + flat.push(it); + } + }; + pushAll(res ? res.top : null); + pushAll(res ? res.songs : null); + pushAll(res ? res.albums : null); + pushAll(res ? res.artists : null); + pushAll(res ? res.playlists : null); + root.remote = flat; + root.searching = false; + }) + .catch(function () { + if (seq !== root.searchSeq || !root.opened || Playback.provider !== prov) + return; + root.remote = []; + root.searchError = "Search could not be loaded. Try again or paste a playlist link."; + root.searching = false; + }); + } + + function isShortcut(id) { + var picks = Personal.picks; + for (var i = 0; i < picks.length; i++) + if (picks[i].id === id) + return true; + return false; + } + + function pickFrom(it) { + var item = {}; + for (var key in it) + item[key] = it[key]; + return item; + } + + function addCandidate(it) { + if (!Personal.hydrated) { + Playback.toast("Your listening library is still loading", "info"); + return; + } + if (!it || !it.id) + return; + if (root.isShortcut(it.id)) { + Playback.toast("Already a shortcut", "info"); + return; + } + if (root.full) { + Playback.toast("Shortcuts are full — remove one first", "info"); + return; + } + if (Personal.addPick(root.pickFrom(it))) + Playback.toast("Pinned " + (it.title || "shortcut"), "success"); + } + + // --- pasted link --------------------------------------------------------------------------- + function resolveLink() { + if (!root.opened) + return; + var parsed = Links.parsePlaylistLink(root.linkInput); + root.linkPreview = null; + root.linkError = ""; + var seq = ++root.linkSeq; + if (parsed.reason === "empty") { + root.linkLoading = false; + return; + } + if (!parsed.ok) { + root.linkLoading = false; + root.linkError = parsed.message; + return; + } + root.linkLoading = true; + Daemon.call("get_playlist", { id: parsed.id }) + .then(function (p) { + if (seq !== root.linkSeq || !root.opened) + return; + root.linkLoading = false; + if (!p || !p.title) { + root.linkError = "That playlist is private, empty or unavailable."; + return; + } + root.linkPreview = { + id: parsed.id, + kind: "playlist", + title: p.title, + subtitle: (p && p.subtitle) ? p.subtitle : "", + thumbnail: (p && (p.cover || p.thumbnail)) ? (p.cover || p.thumbnail) : "" + }; + }) + .catch(function (e) { + if (seq !== root.linkSeq || !root.opened) + return; + root.linkLoading = false; + root.linkError = (e && e.message) ? e.message + : "That playlist could not be opened. It may be private or unavailable."; + }); + } + + function addLink() { + if (!Personal.hydrated) { + Playback.toast("Your listening library is still loading", "info"); + return; + } + var t = root.linkPreview; + if (!t) + return; + if (root.isShortcut(t.id)) { + Playback.toast("Already a shortcut", "info"); + return; + } + if (root.full) { + Playback.toast("Shortcuts are full — remove one first", "info"); + return; + } + Personal.addPick(root.pickFrom(t)); + Playback.toast("Pinned " + t.title, "success"); + root.linkInput = ""; + root.linkPreview = null; + root.linkError = ""; + } + + onLinkInputChanged: { + root.linkSeq++; + root.linkLoading = false; + root.linkPreview = null; + root.linkError = ""; + if (root.opened) + linkTimer.restart(); + } + onQueryChanged: { + root.searchSeq++; + root.remote = []; + root.searching = false; + if (root.opened) + searchTimer.restart(); + } + Connections { + target: Playback + function onProviderChanged(): void { + if (root.opened) + root.open(); + } + } + + Timer { id: searchTimer; interval: 250; onTriggered: root.runSearch() } + Timer { id: linkTimer; interval: 300; onTriggered: root.resolveLink() } + + Keys.onEscapePressed: (event) => { root.close(); event.accepted = true; } + + // snapshot blur of the page behind, captured once on open (spec §9) + ShaderEffectSource { + id: snap + anchors.fill: parent + visible: false + sourceItem: root.blurSource + live: false + hideSource: false + recursive: false + } + MultiEffect { + anchors.fill: parent + visible: root.blurSource !== null && Style.blurEnabled + source: snap + blurEnabled: true + blur: 1.0 + blurMax: 32 + autoPaddingEnabled: false + } + + // dismiss layer + scrim + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: 0.5 + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(root.width - Style.sp(12), Style.sp(150)) + implicitHeight: sheet.implicitHeight + Style.sp(12) + height: Math.min(implicitHeight, root.height - Style.sp(12)) + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + + // swallow clicks so they don't reach the dismiss layer + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: sheet + anchors.fill: parent + anchors.margins: Style.sp(6) + spacing: Style.sp(3) + + // header + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + text: "// PIN / SHORTCUT" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: "Add a shortcut" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.DemiBold + } + } + IconButton { + icon: "close" + iconSize: Style.fs.md + diameter: Style.sp(8) + tip: "Close" + onClicked: root.close() + } + } + + Text { + visible: root.full + Layout.fillWidth: true + text: "Shortcuts are full (" + root.capacity + "). Remove one on Home to add another." + color: Style.alert + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + + // filter / search field + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: filterField.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.md; color: Tokens.inkMuted } + TextInput { + id: filterField + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.query + onTextChanged: root.query = text + onAccepted: root.runSearch() + Keys.onEscapePressed: (event) => { root.close(); event.accepted = true; } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: filterField.text.length === 0 + text: "Filter recents & library, or search…" + color: Tokens.inkFaint + font: filterField.font + } + } + Text { + visible: root.searching + text: "…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + } + + // candidate list + Text { + visible: root.candidates.length === 0 && !root.searching + Layout.fillWidth: true + text: root.searchError || (root.query.trim().length > 0 + ? "No matches. Try a different search, or paste a playlist link below." + : "Play something and it shows up here, or search above.") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + ListView { + id: candidateList + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: Math.min(contentHeight, Style.sp(72)) + visible: root.candidates.length > 0 + clip: true + reuseItems: true + boundsBehavior: Flickable.StopAtBounds + model: root.candidates + spacing: Style.sp(1) + delegate: Rectangle { + id: candRow + required property var modelData + readonly property bool round: candRow.modelData && candRow.modelData.kind === "artist" + readonly property bool pinned: root.isShortcut(candRow.modelData.id) + width: candidateList.width + implicitHeight: Style.sp(12) + radius: Style.radius + color: candHover.hovered ? Tokens.tint5 : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Artwork { + Layout.alignment: Qt.AlignVCenter + url: candRow.modelData.thumbnail ? candRow.modelData.thumbnail : "" + px: Style.sp(9) + round: candRow.round + placeholderIcon: candRow.round ? "user" + : candRow.modelData.kind === "song" ? "music" : "playlist" + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: candRow.modelData.title ? candRow.modelData.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: candRow.modelData.subtitle ? candRow.modelData.subtitle + : (candRow.modelData.kind ? candRow.modelData.kind : "") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + // kind tag + Rectangle { + Layout.alignment: Qt.AlignVCenter + visible: !!candRow.modelData.kind + implicitHeight: Style.sp(6) + implicitWidth: kindLabel.implicitWidth + Style.sp(4) + radius: height / 2 + color: "transparent" + border.width: 1 + border.color: Tokens.line + Text { + id: kindLabel + anchors.centerIn: parent + text: candRow.modelData.kind ? String(candRow.modelData.kind).toUpperCase() : "" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + Btn { + text: candRow.pinned ? "Added" : "Add" + icon: candRow.pinned ? "" : "add" + enabled: !candRow.pinned && !root.full + onClicked: root.addCandidate(candRow.modelData) + } + } + HoverHandler { id: candHover } + } + } + + // divider + Rectangle { Layout.fillWidth: true; implicitHeight: 1; color: Tokens.lineSoft } + + // paste-a-link + Text { + text: "Or paste a YouTube playlist link" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: linkField.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "link"; size: Style.fs.md; color: Tokens.inkMuted } + TextInput { + id: linkField + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.linkInput + onTextChanged: root.linkInput = text + onAccepted: { if (root.linkPreview) root.addLink(); else root.resolveLink(); } + Keys.onEscapePressed: (event) => { root.close(); event.accepted = true; } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: linkField.text.length === 0 + text: "music.youtube.com/playlist?list=…" + color: Tokens.inkFaint + font: linkField.font + } + } + } + } + Btn { + text: "Add" + icon: "add" + primary: true + enabled: !!root.linkPreview && !root.isShortcut(root.linkPreview.id) && !root.full + onClicked: root.addLink() + } + } + + // link readout / preview / error + Text { + visible: root.linkLoading + text: "Resolving playlist…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Text { + visible: root.linkError !== "" + Layout.fillWidth: true + text: root.linkError + color: Style.alert + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Rectangle { + Layout.fillWidth: true + visible: !!root.linkPreview + implicitHeight: Style.sp(12) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Artwork { + Layout.alignment: Qt.AlignVCenter + url: root.linkPreview && root.linkPreview.thumbnail ? root.linkPreview.thumbnail : "" + px: Style.sp(9) + placeholderIcon: "playlist" + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: root.linkPreview ? root.linkPreview.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.linkPreview && root.linkPreview.subtitle ? root.linkPreview.subtitle : "Playlist" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + Text { + visible: root.linkPreview && root.isShortcut(root.linkPreview.id) + text: "Added" + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + } + } +} diff --git a/client/components/Skeleton.qml b/client/components/Skeleton.qml new file mode 100644 index 0000000..583bf47 --- /dev/null +++ b/client/components/Skeleton.qml @@ -0,0 +1,11 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// A neutral loading placeholder. Deliberately static — no shimmer loop — so a page waiting on the +// daemon still costs nothing at idle, per the performance budget. +Rectangle { + property int corner: Style.radius + radius: corner + color: Tokens.tint5 +} diff --git a/client/components/SkinCard.qml b/client/components/SkinCard.qml new file mode 100644 index 0000000..28bc520 --- /dev/null +++ b/client/components/SkinCard.qml @@ -0,0 +1,117 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// One entry in the Settings skin picker: a 200 px card carrying the skin's five-swatch strip +// (paper, paperLift, ink, sun, bone from its default mode — the live Tokens roles for the System +// card), its name, its author in tracked mono, a source badge (SHIPPED / USER / DEV / GENERATED) +// and, on the active skin, a check. Purely presentational: the host feeds `swatches`/`badge` and +// handles the pick through `clicked`. +Rectangle { + id: root + + property string title: "" + property string author: "" + property var swatches: [] // five colour values, left→right + property string badge: "" // "" hides it; GENERATED wins over the source + property bool active: false + signal clicked() + + implicitWidth: Style.sp(50) // 200 px at scale 1 + implicitHeight: col.implicitHeight + Style.sp(6) + radius: Style.radiusCard + color: root.active ? Tokens.tint5 : cardHover.hovered ? Tokens.tint5 : "transparent" + border.width: 1 + border.color: root.active ? Tokens.sun : cardHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + + ColumnLayout { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: Style.sp(3) } + spacing: Style.sp(2) + + // The palette strip: five equal blocks, clipped into the control radius. + Rectangle { + id: strip + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + clip: true + color: Tokens.line + Row { + anchors.fill: parent + Repeater { + model: 5 + delegate: Rectangle { + required property int index + width: strip.width / 5 + height: strip.height + color: (root.swatches && root.swatches.length > index) ? root.swatches[index] : Tokens.line + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { + Layout.fillWidth: true + text: root.title + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Icon { + visible: root.active + name: "check-circle" + size: Style.fs.md + color: Tokens.sun + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Text { + Layout.fillWidth: true + text: root.author + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + } + Rectangle { + visible: root.badge !== "" + implicitHeight: Style.sp(4) + implicitWidth: badgeText.implicitWidth + Style.sp(3) + radius: height / 2 + color: "transparent" + border.width: 1 + border.color: root.badge === "GENERATED" ? Style.accent : Tokens.line + Text { + id: badgeText + anchors.centerIn: parent + text: root.badge + color: root.badge === "GENERATED" ? Style.accent : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + } + + HoverHandler { id: cardHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/client/components/Slider.qml b/client/components/Slider.qml new file mode 100644 index 0000000..ab4eddc --- /dev/null +++ b/client/components/Slider.qml @@ -0,0 +1,78 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// A one-dimensional drag control (volume, and the seek overlay with visuals off). A 2 px track with +// an accent fill; the 12 px handle appears only on hover or while held, so the resting control is a +// quiet hairline (spec section 4). Strictly unidirectional: it never stores its own value. It reads +// `value` (bound by the parent to the model) and only emits `moved`/`committed`; the parent updates +// the model, and the binding feeds the new value straight back, so an incoming daemon echo can never +// fight the pointer. Qt grabs the pointer on press, so `committed` fires even when the release lands +// outside the window. +Item { + id: root + + property real from: 0 + property real to: 1 + property real value: 0 + property bool visualTrack: true + property bool showHandle: true + property int thickness: 2 + property int handleSize: 12 + property color trackColor: Tokens.lineStrong + property color fillColor: Style.accent + + readonly property bool pressed: ma.pressed + readonly property real pct: to > from ? Math.max(0, Math.min(1, (value - from) / (to - from))) : 0 + readonly property bool showKnob: root.visualTrack && root.showHandle && (hover.hovered || ma.pressed) + + signal moved(real v) + signal committed(real v) + + implicitWidth: 120 + implicitHeight: Math.max(handleSize, thickness) + Style.sp(2) + + function _valueAt(x) { + var t = width > 0 ? Math.max(0, Math.min(1, x / width)) : 0; + return from + t * (to - from); + } + + Rectangle { + visible: root.visualTrack + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: root.thickness + radius: height / 2 + color: root.trackColor + } + Rectangle { + visible: root.visualTrack + anchors.verticalCenter: parent.verticalCenter + width: parent.width * root.pct + height: root.thickness + radius: height / 2 + color: root.fillColor + } + Rectangle { + visible: root.showKnob + width: root.handleSize + height: root.handleSize + radius: height / 2 + color: root.fillColor + anchors.verticalCenter: parent.verticalCenter + x: parent.width * root.pct - width / 2 + } + + HoverHandler { id: hover } + MouseArea { + id: ma + anchors.fill: parent + anchors.topMargin: -Style.sp(2) + anchors.bottomMargin: -Style.sp(2) + preventStealing: true + cursorShape: Qt.PointingHandCursor + onPressed: (m) => root.moved(root._valueAt(m.x)) + onPositionChanged: (m) => { if (ma.pressed) root.moved(root._valueAt(m.x)); } + onReleased: (m) => root.committed(root._valueAt(m.x)) + } +} diff --git a/client/components/SpotifyGate.qml b/client/components/SpotifyGate.qml new file mode 100644 index 0000000..85547b4 --- /dev/null +++ b/client/components/SpotifyGate.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// The Spotify sign-in gate a page shows in place of its content while the provider is Spotify +// and no Premium session exists. It reflects the OAuth flow: idle, waiting on the browser, or +// the daemon's failure reason (a free account, a cancelled page) - so a click always visibly +// leads somewhere. +ColumnLayout { + id: root + spacing: Style.sp(3) + + readonly property var sp: Playback.spotify || ({}) + readonly property bool waiting: root.sp.flow === "browser" + readonly property string failure: root.sp.error || "" + + Icon { + Layout.alignment: Qt.AlignHCenter + name: "spotify" + size: Style.sp(10) + color: Style.providerColor + } + Btn { + Layout.alignment: Qt.AlignHCenter + text: root.waiting ? "Waiting for your browser\u2026" : (root.failure ? "Try another account" : "Sign in to Spotify") + primary: true + onClicked: Playback.spotifySignIn().catch(() => {}) + } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Style.sp(110) + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: root.failure + ? root.failure + "\nSpotify streams only to Premium accounts; YouTube Music plays without one." + : (root.waiting ? "Finish the authorization in the tab that opened, then come back here." + : "Premium is required for playback.") + color: root.failure ? Style.accent : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } +} diff --git a/client/components/Toggle.qml b/client/components/Toggle.qml new file mode 100644 index 0000000..4d6edfe --- /dev/null +++ b/client/components/Toggle.qml @@ -0,0 +1,45 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// The on/off switch the settings surface uses in place of the Svelte `Switch`. Purely +// unidirectional like Slider: it renders `checked` (bound to the model) and only emits `toggled`; +// the host flips the daemon setting and the binding feeds the new value back. The knob/track +// animations run only on a state flip, never at idle, so an untouched switch costs no wakeups. +Item { + id: root + + property bool checked: false + signal toggled(bool value) + + implicitWidth: Style.sp(11) + implicitHeight: Style.sp(6) + opacity: enabled ? 1 : 0.5 + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: root.checked ? Tokens.sun : Tokens.tint16 + border.width: 1 + border.color: root.checked ? Tokens.sun : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + + Rectangle { + id: knob + width: Style.sp(4.5) + height: Style.sp(4.5) + radius: height / 2 + anchors.verticalCenter: parent.verticalCenter + x: root.checked ? parent.width - width - Style.sp(0.75) : Style.sp(0.75) + color: root.checked ? Tokens.inkOnBone : Tokens.ink + Behavior on x { NumberAnimation { duration: Style.motion.snap; easing.type: Easing.OutQuad } } + } + + MouseArea { + anchors.fill: parent + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled(!root.checked) + } +} diff --git a/client/components/TrackList.qml b/client/components/TrackList.qml new file mode 100644 index 0000000..ed95442 --- /dev/null +++ b/client/components/TrackList.qml @@ -0,0 +1,255 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// A reused ListView of TrackRow, the shared body of every track surface (playlist, album, library +// songs, queue). reuseItems keeps a long list to a couple of screenfuls of live delegates, and a +// bounded cacheBuffer keeps the scroll cheap. An optional page header/footer scrolls with the rows, +// so a page's hero and its card carousels live in one scroller rather than nesting a second +// Flickable. When `showHeader`, a micro column legend (# TITLE ARTIST ALBUM PLAYS LENGTH) is +// drawn over a hairline below that page header, aligned to the row columns. When `reorderable`, a +// grip on each row drags it to a new index and commits with `moved(from,to)` (the queue wires that +// to move_in_queue); the daemon's queue-changed event repaints the result. +Item { + id: root + + property var items: [] + property Component header: null + property Component footer: null + + property bool reorderable: false + property bool hideThumb: false + property bool showHeader: false + property bool showAlbum: false + property bool showPlays: false + property bool menu: true + property bool canAdd: true + property bool canRemove: false + property string removeLabel: "Remove from playlist" + property string source: "" + + // Play-target and reorder/remove contracts the host owns. + signal activated(int index) + signal moved(int from, int to) + signal removeAt(int index) + + readonly property int rowHeight: Style.rowH + readonly property alias view: list + readonly property bool wideAlbum: root.showAlbum && root.width >= Style.sp(225) + + // --- drag-reorder state ----------------------------------------------------------------- + property int dragFrom: -1 + property int dragTo: -1 + property real ghostY: 0 + property string ghostTitle: "" + + function headerHeight() { return list.headerItem ? list.headerItem.height : 0; } + function indexAt(yInList) { + var y = list.contentY + yInList - root.headerHeight(); + return Math.max(0, Math.min(root.items.length - 1, Math.floor(y / root.rowHeight))); + } + function beginDrag(from, yInList) { + root.dragFrom = from; + root.dragTo = from; + root.ghostY = yInList; + var it = root.items[from]; + root.ghostTitle = it ? it.title : ""; + } + function updateDrag(yInList) { + root.ghostY = yInList; + root.dragTo = root.indexAt(yInList); + // Edge auto-scroll when the pointer nears a boundary. + var edge = Style.sp(12); + if (yInList < edge) + list.contentY = Math.max(list.originY, list.contentY - Style.sp(4)); + else if (yInList > list.height - edge) + list.contentY = Math.min(list.originY + list.contentHeight - list.height, list.contentY + Style.sp(4)); + } + function endDrag() { + if (root.dragFrom >= 0 && root.dragTo >= 0 && root.dragFrom !== root.dragTo) + root.moved(root.dragFrom, root.dragTo); + root.dragFrom = -1; + root.dragTo = -1; + } + + ListView { + id: list + anchors.fill: parent + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + model: root.items + header: (root.showHeader || root.header) ? listHead : null + footer: root.footer + // A drag in progress must not also flick the list. + interactive: root.dragFrom < 0 + + delegate: Item { + id: rowWrap + required property var modelData + required property int index + width: list.width + implicitHeight: root.rowHeight + + // The drop marker: a hairline where this row would land. + Rectangle { + visible: root.dragFrom >= 0 && root.dragTo === rowWrap.index + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 2 + color: Style.accent + } + + TrackRow { + id: rowItem + anchors.left: parent.left + anchors.right: parent.right + index: root.showHeader ? rowWrap.index : -1 + song: rowWrap.modelData + hideThumb: root.hideThumb + showAlbum: root.showAlbum + showPlays: root.showPlays + tableColumns: root.showHeader + menu: root.menu + canAdd: root.canAdd + canRemove: root.canRemove + removeLabel: root.removeLabel + opacity: root.dragFrom === rowWrap.index ? 0.35 : 1 + active: !!(Playback.now && rowWrap.modelData + && Playback.now.videoId === rowWrap.modelData.video_id) + onPlay: root.activated(rowWrap.index) + onMenuRequested: (sx, sy) => { + var p = ctxMenu.mapFromItem(null, sx, sy); + ctxMenu.song = rowWrap.modelData; + ctxMenu.canAdd = root.canAdd; + ctxMenu.canRemove = root.canRemove; + ctxMenu.removeLabel = root.removeLabel; + ctxMenu.source = root.source; + ctxMenu._index = rowWrap.index; + ctxMenu.openAt(p.x, p.y); + } + } + + // Reorder grip (right edge). Vertical drag moves the row; the whole list stops flicking + // while a grip is held (list.interactive above). + Item { + id: grip + visible: root.reorderable + width: root.reorderable ? Style.sp(9) : 0 + height: parent.height + anchors.right: parent.right + Column { + anchors.centerIn: parent + spacing: Style.sp(0.75) + Repeater { + model: 3 + delegate: Rectangle { + width: Style.sp(4) + height: Math.max(1, Style.sp(0.5)) + radius: height / 2 + color: gripDrag.active ? Tokens.ink : Tokens.inkFaint + } + } + } + DragHandler { + id: gripDrag + target: null + onActiveChanged: { + if (active) + root.beginDrag(rowWrap.index, grip.mapToItem(list, 0, centroid.position.y).y); + else + root.endDrag(); + } + onCentroidChanged: { + if (active) + root.updateDrag(grip.mapToItem(list, 0, centroid.position.y).y); + } + } + } + } + } + + // The page header (a hero, carousels) then, when asked, the micro column legend over a hairline. + Component { + id: listHead + Column { + width: list.width + Loader { + width: list.width + active: root.header !== null + sourceComponent: root.header + } + Item { + width: list.width + visible: root.showHeader + height: root.showHeader ? Style.sp(8) : 0 + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + HeadLabel { text: "#"; Layout.preferredWidth: Style.sp(10); horizontalAlignment: Text.AlignHCenter } + Item { visible: !root.hideThumb; Layout.preferredWidth: Style.sp(10) } + HeadLabel { text: "TITLE"; Layout.fillWidth: true; Layout.preferredWidth: Style.sp(60) } + HeadLabel { text: "ARTIST"; Layout.fillWidth: true; Layout.preferredWidth: Style.sp(40) } + HeadLabel { text: "ALBUM"; visible: root.wideAlbum; Layout.preferredWidth: Style.sp(48) } + HeadLabel { text: "PLAYS"; visible: root.showPlays; Layout.preferredWidth: Style.sp(22); horizontalAlignment: Text.AlignRight } + HeadLabel { text: "LENGTH"; Layout.preferredWidth: Style.sp(14); horizontalAlignment: Text.AlignRight } + Item { Layout.preferredWidth: root.menu ? Style.sp(19) : Style.sp(9) } + } + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 1 + color: Tokens.line + } + } + } + } + + // A column-legend cell: the tracked mono micro label. + component HeadLabel: Text { + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // The floating row that follows the grip during a reorder. + Rectangle { + visible: root.dragFrom >= 0 + width: list.width - Style.sp(6) + x: Style.sp(3) + y: Math.max(0, Math.min(root.height - height, root.ghostY - height / 2)) + implicitHeight: root.rowHeight + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + opacity: 0.95 + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + text: root.ghostTitle + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + } + + Menu { + id: ctxMenu + property int _index: -1 + onRemoveRequested: root.removeAt(ctxMenu._index) + } +} diff --git a/client/components/TrackRow.qml b/client/components/TrackRow.qml new file mode 100644 index 0000000..9692db9 --- /dev/null +++ b/client/components/TrackRow.qml @@ -0,0 +1,278 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../lib/ids.js" as Ids + +// One track-table row (Sonora's list in Ryoku's skin, spec section 4). The whole row is the play +// target; the leading slot carries the track number, swapping to a play glyph on hover and to an +// accent note while this is the row that is playing. Columns — index | art | title / artist | album +// | plays | length | actions — line up with the micro header TrackList draws when `showHeader`. +// Rating is optimistic and hidden for local files and radio, which YouTube cannot rate. +Rectangle { + id: root + + property var song: null + property int index: -1 + property bool active: false + // Full-table opt-ins (default off, so the queue / library rows stay lean): the album column + // (only when the row is at least 900 px wide), the plays column and the ⋯ menu with its + // context items. `menuRequested` carries scene coords for the list's shared Menu. + property bool showAlbum: false + property bool showPlays: false + // A true table (separate title / artist columns aligned to TrackList's header). Off by + // default, so a narrow surface (the queue panel, search rows) stacks artist beneath the title. + property bool tableColumns: false + property bool hideThumb: false + property bool menu: false + property bool canAdd: false + property bool canRemove: false + property string removeLabel: "Remove from playlist" + signal play() + signal menuRequested(real sx, real sy) + + // Column widths — shared with TrackList's header so the two line up (same Style.sp constants, + // same RowLayout margins and spacing). + readonly property bool wideAlbum: root.showAlbum && root.width >= Style.sp(225) + + // Seeded from the row's own snapshot; a rating call updates it optimistically. Reset when the + // delegate is reused for a different song. + property string rated: (song && song.rating) ? song.rating : "indifferent" + onSongChanged: rated = (song && song.rating) ? song.rating : "indifferent" + + readonly property bool canRate: !!song && !Ids.isLocalId(song.video_id) && !Ids.isRadioId(song.video_id) + readonly property string duration: (song && song.duration && /^[\d:]+$/.test(song.duration)) ? song.duration : "" + readonly property string album: (song && song.album) ? song.album : "" + readonly property string plays: (song && song.play_count) ? song.play_count : "" + + implicitHeight: Style.rowH + radius: Style.radius + color: root.active ? Tokens.tint10 : rowHover.hovered ? Tokens.tint5 : "transparent" + + function toggleLike() { + if (!root.canRate || !root.song) + return; + var next = root.rated === "like" ? "indifferent" : "like"; + root.rated = next; + Daemon.call("rate", { videoId: root.song.video_id, rating: next }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not rate", "error")); + } + + MouseArea { + id: playArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: root.menu ? (Qt.LeftButton | Qt.RightButton) : Qt.LeftButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + var p = playArea.mapToItem(null, mouse.x, mouse.y); + root.menuRequested(p.x, p.y); + return; + } + root.play(); + } + } + + // The recurring one-pixel divider between rows (Sonora's table rule). Softened under the + // playing row so its tint reads as one block. + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + height: 1 + color: Tokens.lineSoft + visible: !root.active + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + + // index / play-on-hover / accent note while playing + Item { + Layout.preferredWidth: Style.sp(10) + Layout.fillHeight: true + Text { + anchors.centerIn: parent + visible: !root.active && !rowHover.hovered && root.index >= 0 + text: root.index + 1 + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + } + Icon { + anchors.centerIn: parent + visible: !root.active && rowHover.hovered + name: "play" + size: Style.fs.md + color: Tokens.ink + } + Icon { + anchors.centerIn: parent + visible: root.active + name: "music" + size: Style.fs.md + color: Style.accent + } + } + + // artwork (album track lists hide it — the number is the identity there) + Artwork { + visible: !root.hideThumb + Layout.preferredWidth: Style.sp(10) + Layout.preferredHeight: Style.sp(10) + url: root.song && root.song.thumbnail ? root.song.thumbnail : "" + px: Style.sp(10) + } + + // title / artist — stacked beneath each other on a narrow surface (queue, search rows) + ColumnLayout { + visible: !root.tableColumns + Layout.fillWidth: true + Layout.preferredWidth: Style.sp(60) + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { + Layout.fillWidth: true + text: root.song ? root.song.title : "" + color: root.active ? Style.accent : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Rectangle { + visible: !!(root.song && root.song.explicit) + Layout.preferredWidth: Style.sp(4) + Layout.preferredHeight: Style.sp(4) + radius: Style.sp(1) + color: "transparent" + border.width: 1 + border.color: Tokens.inkFaint + Text { anchors.centerIn: parent; text: "E"; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; font.weight: Font.DemiBold } + } + } + Text { + Layout.fillWidth: true + text: (root.song && root.song.artists) ? root.song.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + // title column — full table (aligns to the # TITLE header) + RowLayout { + visible: root.tableColumns + Layout.fillWidth: true + Layout.preferredWidth: Style.sp(60) + spacing: Style.sp(1.5) + Text { + Layout.fillWidth: true + text: root.song ? root.song.title : "" + color: root.active ? Style.accent : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Rectangle { + visible: !!(root.song && root.song.explicit) + Layout.preferredWidth: Style.sp(4) + Layout.preferredHeight: Style.sp(4) + radius: Style.sp(1) + color: "transparent" + border.width: 1 + border.color: Tokens.inkFaint + Text { anchors.centerIn: parent; text: "E"; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.xs; font.weight: Font.DemiBold } + } + } + + // artist column — full table + Text { + visible: root.tableColumns + Layout.fillWidth: true + Layout.preferredWidth: Style.sp(40) + text: (root.song && root.song.artists) ? root.song.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + + // album + Text { + visible: root.wideAlbum + Layout.preferredWidth: Style.sp(48) + text: root.album + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + + // plays + Text { + visible: root.showPlays + Layout.preferredWidth: Style.sp(22) + horizontalAlignment: Text.AlignRight + text: root.plays + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + + // length + Text { + Layout.preferredWidth: Style.sp(14) + horizontalAlignment: Text.AlignRight + text: root.duration + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + + // actions: like + more + Item { + Layout.preferredWidth: root.menu ? Style.sp(19) : Style.sp(9) + Layout.fillHeight: true + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Style.sp(1) + IconButton { + visible: root.canRate + icon: "heart" + iconSize: Style.fs.md + diameter: Style.sp(9) + opacity: (rowHover.hovered || root.rated === "like") ? 1 : 0 + active: root.rated === "like" + iconColor: root.rated === "like" ? Style.accent : Tokens.inkMuted + onClicked: root.toggleLike() + } + IconButton { + id: moreBtn + visible: root.menu + icon: "more" + iconSize: Style.fs.md + diameter: Style.sp(9) + opacity: rowHover.hovered ? 1 : 0 + iconColor: Tokens.inkMuted + onClicked: { + var p = moreBtn.mapToItem(null, 0, moreBtn.height); + root.menuRequested(p.x, p.y); + } + } + } + } + } + + HoverHandler { id: rowHover } +} diff --git a/client/components/Waveform.qml b/client/components/Waveform.qml new file mode 100644 index 0000000..90afd30 --- /dev/null +++ b/client/components/Waveform.qml @@ -0,0 +1,139 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Ryoku.Ui.Singletons +import "../" + +// The SoundCloud seek bar (the Orange look): the track's 240-sample waveform drawn as thin bars with +// a mirrored reflection, the played portion in the provider colour and the rest in inkFaint. It is a +// seek control — click or drag scrubs through Playback.seek(seconds) — and hovering shows a time +// tooltip with a scrub line. Rendered on a Canvas (240 rects, repainted only when the samples, the +// played fraction, the colours or the size change), so it costs nothing per frame. Height is 56 on +// the Now Playing stage and Home card; the player bar sizes it to 28. The one animated property is +// opacity, so a missing waveform fades rather than pops when it resolves. +Item { + id: root + + // The 0..100 amplitude samples the daemon reports (get_waveform); 240 of them by contract, but + // any length is resampled to `count` bars. + property var samples: [] + // What the bar split reads from; defaults to the live transport (the held thumb while dragging). + property real position: Playback.shownPosition + property real duration: Playback.duration + property color playedColor: Style.providerColor + property color restColor: Tokens.inkFaint + property bool seekable: true + + readonly property int count: 240 + readonly property real fraction: root.duration > 0 + ? Math.max(0, Math.min(1, root.position / root.duration)) : 0 + // Normalise against the loudest sample so a quiet track still fills the height. + readonly property real peak: { + var s = root.samples, m = 1; + if (s) + for (var i = 0; i < s.length; i++) + if (s[i] > m) + m = s[i]; + return m; + } + + implicitHeight: 56 + + // Only appears once samples exist; the fade is the sole animated property. + opacity: (root.samples && root.samples.length > 0) ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: Style.motion.swap } } + + onSamplesChanged: cv.requestPaint() + onFractionChanged: cv.requestPaint() + onPeakChanged: cv.requestPaint() + onPlayedColorChanged: cv.requestPaint() + onRestColorChanged: cv.requestPaint() + + Canvas { + id: cv + anchors.fill: parent + onWidthChanged: cv.requestPaint() + onHeightChanged: cv.requestPaint() + onPaint: { + var ctx = cv.getContext("2d"); + var w = cv.width, h = cv.height; + ctx.clearRect(0, 0, w, h); + var s = root.samples; + if (!s || !s.length || w <= 0 || h <= 0) + return; + var n = root.count; + var slot = w / n; + var barW = Math.max(1, slot * 0.66); // 2 px bar / 1 px gap at full width + var baseY = h * 0.70; // 70 % above the line, 30 % reflection + var topH = h * 0.70, botH = h * 0.30; + var played = root.fraction * n; + for (var i = 0; i < n; i++) { + var idx = Math.floor(i * s.length / n); + var a = Math.max(0.05, s[idx] / root.peak); + var bx = i * slot + (slot - barW) / 2; + var mh = a * topH, bh = a * botH; + ctx.fillStyle = i < played ? root.playedColor : root.restColor; + ctx.globalAlpha = 1; + ctx.fillRect(bx, baseY - mh, barW, mh); + ctx.globalAlpha = 0.35; + ctx.fillRect(bx, baseY, barW, bh); + } + ctx.globalAlpha = 1; + } + } + + // Scrub line under the pointer while hovering the bar. + Rectangle { + visible: root.seekable && seek.containsMouse && seek.hoverX >= 0 + x: Math.max(0, Math.min(root.width - 1, seek.hoverX)) + width: 1 + height: parent.height + color: Qt.rgba(Tokens.ink.r, Tokens.ink.g, Tokens.ink.b, 0.35) + } + + // The time tooltip, floated above the pointer. + Rectangle { + id: tip + visible: root.seekable && seek.containsMouse && seek.hoverX >= 0 && root.duration > 0 + x: Math.max(0, Math.min(root.width - width, seek.hoverX - width / 2)) + y: -height - Style.sp(1) + implicitWidth: tipText.implicitWidth + Style.sp(2) + implicitHeight: tipText.implicitHeight + Style.sp(1) + radius: Style.radius - 2 + color: Tokens.bone + Text { + id: tipText + anchors.centerIn: parent + text: Style.fmtTime((seek.hoverX / Math.max(1, root.width)) * root.duration) + color: Tokens.inkOnBone + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + } + + MouseArea { + id: seek + anchors.fill: parent + enabled: root.seekable + hoverEnabled: true + cursorShape: root.seekable ? Qt.PointingHandCursor : Qt.ArrowCursor + property real hoverX: -1 + function frac(x) { return Math.max(0, Math.min(1, x / Math.max(1, root.width))); } + onPositionChanged: (m) => { + hoverX = m.x; + if (pressed) + Playback.seekDrag = seek.frac(m.x) * root.duration; + } + onExited: hoverX = -1 + onPressed: (m) => { + hoverX = m.x; + Playback.seekDrag = seek.frac(m.x) * root.duration; + } + onReleased: (m) => { + var secs = seek.frac(m.x) * root.duration; + Playback.seek(secs); + Playback.seekDrag = NaN; + } + onCanceled: Playback.seekDrag = NaN + } +} diff --git a/client/icons/account.svg b/client/icons/account.svg new file mode 100644 index 0000000..b8a0398 --- /dev/null +++ b/client/icons/account.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/add.svg b/client/icons/add.svg new file mode 100644 index 0000000..a9e3fc6 --- /dev/null +++ b/client/icons/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/alert.svg b/client/icons/alert.svg new file mode 100644 index 0000000..2cd9d48 --- /dev/null +++ b/client/icons/alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/arrow-down.svg b/client/icons/arrow-down.svg new file mode 100644 index 0000000..d1ef39d --- /dev/null +++ b/client/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/arrow-left.svg b/client/icons/arrow-left.svg new file mode 100644 index 0000000..aec4bd9 --- /dev/null +++ b/client/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/arrow-right.svg b/client/icons/arrow-right.svg new file mode 100644 index 0000000..08f1902 --- /dev/null +++ b/client/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/arrow-up.svg b/client/icons/arrow-up.svg new file mode 100644 index 0000000..63c7867 --- /dev/null +++ b/client/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/artists.svg b/client/icons/artists.svg new file mode 100644 index 0000000..0719ef3 --- /dev/null +++ b/client/icons/artists.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/icons/cd.svg b/client/icons/cd.svg new file mode 100644 index 0000000..7b0f400 --- /dev/null +++ b/client/icons/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/check-circle.svg b/client/icons/check-circle.svg new file mode 100644 index 0000000..ebf77a8 --- /dev/null +++ b/client/icons/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/clock.svg b/client/icons/clock.svg new file mode 100644 index 0000000..e7cefde --- /dev/null +++ b/client/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/close.svg b/client/icons/close.svg new file mode 100644 index 0000000..8a6665a --- /dev/null +++ b/client/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/dashboard.svg b/client/icons/dashboard.svg new file mode 100644 index 0000000..cda5c34 --- /dev/null +++ b/client/icons/dashboard.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/icons/discord.svg b/client/icons/discord.svg new file mode 100644 index 0000000..1c63812 --- /dev/null +++ b/client/icons/discord.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/icons/edit.svg b/client/icons/edit.svg new file mode 100644 index 0000000..ac90bdd --- /dev/null +++ b/client/icons/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/group.svg b/client/icons/group.svg new file mode 100644 index 0000000..3757538 --- /dev/null +++ b/client/icons/group.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/client/icons/heart.svg b/client/icons/heart.svg new file mode 100644 index 0000000..4a7d82d --- /dev/null +++ b/client/icons/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/home.svg b/client/icons/home.svg new file mode 100644 index 0000000..27bffe4 --- /dev/null +++ b/client/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/infinity.svg b/client/icons/infinity.svg new file mode 100644 index 0000000..4fa9a5e --- /dev/null +++ b/client/icons/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/info.svg b/client/icons/info.svg new file mode 100644 index 0000000..d196dd6 --- /dev/null +++ b/client/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/jump-back.svg b/client/icons/jump-back.svg new file mode 100644 index 0000000..a273473 --- /dev/null +++ b/client/icons/jump-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/library.svg b/client/icons/library.svg new file mode 100644 index 0000000..e6e0ffe --- /dev/null +++ b/client/icons/library.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/client/icons/link.svg b/client/icons/link.svg new file mode 100644 index 0000000..18367ad --- /dev/null +++ b/client/icons/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/loading.svg b/client/icons/loading.svg new file mode 100644 index 0000000..b81e5a2 --- /dev/null +++ b/client/icons/loading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/icons/mic.svg b/client/icons/mic.svg new file mode 100644 index 0000000..629b2ff --- /dev/null +++ b/client/icons/mic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/minimize.svg b/client/icons/minimize.svg new file mode 100644 index 0000000..e0e38fc --- /dev/null +++ b/client/icons/minimize.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/music.svg b/client/icons/music.svg new file mode 100644 index 0000000..b76c7a0 --- /dev/null +++ b/client/icons/music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/icons/next.svg b/client/icons/next.svg new file mode 100644 index 0000000..8169d75 --- /dev/null +++ b/client/icons/next.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/on-repeat.svg b/client/icons/on-repeat.svg new file mode 100644 index 0000000..8ff305b --- /dev/null +++ b/client/icons/on-repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/icons/pause.svg b/client/icons/pause.svg new file mode 100644 index 0000000..4cc285e --- /dev/null +++ b/client/icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/pin.svg b/client/icons/pin.svg new file mode 100644 index 0000000..ac16bf6 --- /dev/null +++ b/client/icons/pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/play.svg b/client/icons/play.svg new file mode 100644 index 0000000..5aa3aab --- /dev/null +++ b/client/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/icons/playlist.svg b/client/icons/playlist.svg new file mode 100644 index 0000000..8d6582c --- /dev/null +++ b/client/icons/playlist.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/icons/previous.svg b/client/icons/previous.svg new file mode 100644 index 0000000..86af8f9 --- /dev/null +++ b/client/icons/previous.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/queue.svg b/client/icons/queue.svg new file mode 100644 index 0000000..e0b94e2 --- /dev/null +++ b/client/icons/queue.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/icons/radio.svg b/client/icons/radio.svg new file mode 100644 index 0000000..aa86ea3 --- /dev/null +++ b/client/icons/radio.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/client/icons/rail-collapse.svg b/client/icons/rail-collapse.svg new file mode 100644 index 0000000..a6e31a2 --- /dev/null +++ b/client/icons/rail-collapse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/rail-expand.svg b/client/icons/rail-expand.svg new file mode 100644 index 0000000..29c9793 --- /dev/null +++ b/client/icons/rail-expand.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/repeat-one.svg b/client/icons/repeat-one.svg new file mode 100644 index 0000000..464b89b --- /dev/null +++ b/client/icons/repeat-one.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/icons/repeat.svg b/client/icons/repeat.svg new file mode 100644 index 0000000..3483a9a --- /dev/null +++ b/client/icons/repeat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/search.svg b/client/icons/search.svg new file mode 100644 index 0000000..180b5fa --- /dev/null +++ b/client/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/settings.svg b/client/icons/settings.svg new file mode 100644 index 0000000..3ddc5f9 --- /dev/null +++ b/client/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/shuffle.svg b/client/icons/shuffle.svg new file mode 100644 index 0000000..10ecdb8 --- /dev/null +++ b/client/icons/shuffle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/user.svg b/client/icons/user.svg new file mode 100644 index 0000000..635dec4 --- /dev/null +++ b/client/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/volume-mute.svg b/client/icons/volume-mute.svg new file mode 100644 index 0000000..78eccaa --- /dev/null +++ b/client/icons/volume-mute.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/icons/volume.svg b/client/icons/volume.svg new file mode 100644 index 0000000..4474d95 --- /dev/null +++ b/client/icons/volume.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/lib/browse.js b/client/lib/browse.js new file mode 100644 index 0000000..a268a68 --- /dev/null +++ b/client/lib/browse.js @@ -0,0 +1,50 @@ +.pragma library + +// BrowseItem -> SongItem, ported from ui/src/lib/browse.ts asSong. A card or shelf row carries +// everything a queue entry needs; the daemon's `play`/`play_playlist` deserialize this snake_case +// shape straight into a SongItem. Kept pure so every view component maps items the same way. +function asSong(i) { + var runId; + if (i.artistRuns) { + for (var k = 0; k < i.artistRuns.length; k++) { + if (i.artistRuns[k].id) { runId = i.artistRuns[k].id; break; } + } + } + return { + video_id: i.id, + title: i.title, + artists: i.subtitle || "", + artist_runs: i.artistRuns, + artist_id: runId || i.artistId, + duration: i.duration, + play_count: i.playCount, + thumbnail: i.thumbnail, + explicit: i.explicit, + is_upload: i.isUpload + }; +} + +// The four-per-column song layout a "mostly songs" shelf uses: split an ordered song list into +// columns of `rows`. Mirrors Shelf.svelte's `columns` derivation. +function columnize(songs, rows) { + var cols = []; + for (var c = 0; c < Math.ceil(songs.length / rows); c++) + cols.push(songs.slice(c * rows, c * rows + rows)); + return cols; +} + +// The dominant kind of a shelf, and whether it clears the 75% "mostly one thing" bar that earns a +// per-kind form; below it the shelf is a mixed bag drawn as plain cards. Ported from Shelf.svelte. +function shelfMode(items) { + if (!items || !items.length) + return "card"; + var counts = {}; + var best = null; + var bestN = 0; + for (var i = 0; i < items.length; i++) { + var k = items[i].kind; + counts[k] = (counts[k] || 0) + 1; + if (counts[k] > bestN) { bestN = counts[k]; best = k; } + } + return bestN / items.length >= 0.75 ? best : "card"; +} diff --git a/client/lib/collections.js b/client/lib/collections.js new file mode 100644 index 0000000..159330e --- /dev/null +++ b/client/lib/collections.js @@ -0,0 +1,61 @@ +.pragma library + +// Collection grouping for the downloads queue/history. The daemon stamps every batch-added job +// with its `collection` label and `collectionKind`; the page collapses jobs that share a label +// into one card so a 40-track album reads as one line, while single tracks stay flat rows. +// Pure over plain objects (the daemon's job JSON) so it runs under qmltestrunner. +// +// Result shape: a list of `{ job }` (flat row) or `{ group }` entries, where a group is +// `{ label, kind, jobs }`. A group is placed where its first member sorted, preserving the +// caller's queue/history order. + +function groupJobs(list) { + var out = []; + var byLabel = {}; + for (var i = 0; i < list.length; i++) { + var j = list[i]; + var label = (j && j.collection) ? String(j.collection) : ""; + if (label === "") { + out.push({ job: j }); + continue; + } + var g = byLabel[label]; + if (!g) { + g = { label: label, kind: j.collectionKind || "collection", jobs: [] }; + byLabel[label] = g; + out.push({ group: g }); + } + g.jobs.push(j); + } + return out; +} + +// Aggregate progress for one collection card. Completed tracks count as full; a downloading +// one contributes its own percent; failed/cancelled count as finished attempts (they stop the +// bar from ever reaching 100 while nothing is moving). +function stats(jobs) { + var s = { done: 0, waiting: 0, moving: 0, failed: 0, cancelled: 0, percent: 0 }; + if (!jobs || jobs.length === 0) + return s; + var sum = 0; + for (var i = 0; i < jobs.length; i++) { + var j = jobs[i]; + if (j.status === "completed") { + s.done++; + sum += 100; + } else if (j.status === "downloading") { + s.moving++; + sum += Math.max(0, Math.min(100, j.progress || 0)); + } else if (j.status === "queued") { + s.waiting++; + } else if (j.status === "failed") { + s.failed++; + sum += 100; + } else if (j.status === "cancelled") { + s.cancelled++; + sum += 100; + } + } + s.percent = Math.round(sum / jobs.length); + return s; +} diff --git a/client/lib/glyphs.js b/client/lib/glyphs.js new file mode 100644 index 0000000..bbc25f6 --- /dev/null +++ b/client/lib/glyphs.js @@ -0,0 +1,367 @@ +.pragma library +// Baked from client/icons/*.svg (Hugeicons free set, MIT): stroke paths in a 24x24 box. +// Rendered by components/Icon.qml as scene-graph geometry (QtQuick.Shapes), no offscreen layer. +var glyphs = { + "account": { + "d": "M15.5 10.5C15.5 8.567 13.933 7 12 7C10.067 7 8.5 8.567 8.5 10.5C8.5 12.433 10.067 14 12 14C13.933 14 15.5 12.433 15.5 10.5Z M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z M18 20C18 16.6863 15.3137 14 12 14C8.68629 14 6 16.6863 6 20", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "add": { + "d": "M12.001 5.00003V19.002 M19.002 12.002L4.99998 12.002", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "alert": { + "d": "M2.0 12.0a10.0 10.0 0 1 0 20.0 0a10.0 10.0 0 1 0 -20.0 0 M12 8V12 M12.125 15.75H12M12.25 15.75C12.25 15.8881 12.1381 16 12 16C11.8619 16 11.75 15.8881 11.75 15.75C11.75 15.6119 11.8619 15.5 12 15.5C12.1381 15.5 12.25 15.6119 12.25 15.75Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "download": { + "d": "M12 4V15M12 15L8 11M12 15L16 11M4 17V18C4 19.1046 4.89543 20 6 20H18C19.1046 20 20 19.1046 20 18V17", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "arrow-down": { + "d": "M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "arrow-left": { + "d": "M15 6C15 6 9.00001 10.4189 9 12C8.99999 13.5812 15 18 15 18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "arrow-right": { + "d": "M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "arrow-up": { + "d": "M17.9998 15C17.9998 15 13.5809 9.00001 11.9998 9C10.4187 8.99999 5.99985 15 5.99985 15", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "artists": { + "d": "M13 7C13 9.20914 11.2091 11 9 11C6.79086 11 5 9.20914 5 7C5 4.79086 6.79086 3 9 3C11.2091 3 13 4.79086 13 7Z M15 11C17.2091 11 19 9.20914 19 7C19 4.79086 17.2091 3 15 3 M11 14H7C4.23858 14 2 16.2386 2 19C2 20.1046 2.89543 21 4 21H14C15.1046 21 16 20.1046 16 19C16 16.2386 13.7614 14 11 14Z M17 14C19.7614 14 22 16.2386 22 19C22 20.1046 21.1046 21 20 21H18.5", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "cd": { + "d": "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z M6.5 15.9897C6.89898 16.5649 7.38193 17.0746 7.93053 17.5M16.0261 6.5C16.5947 6.94079 17.0928 7.47217 17.5 8.07321 M14.5 12C14.5 10.6193 13.3807 9.5 12 9.5C10.6193 9.5 9.5 10.6193 9.5 12C9.5 13.3807 10.6193 14.5 12 14.5C13.3807 14.5 14.5 13.3807 14.5 12Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "check-circle": { + "d": "M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z M8 12.5L10.5 15L16 9", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "clock": { + "d": "M2.0 12.0a10.0 10.0 0 1 0 20.0 0a10.0 10.0 0 1 0 -20.0 0 M12 8V12L14 14", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "close": { + "d": "M18 6L6.00081 17.9992M17.9992 18L6 6.00085", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "dashboard": { + "d": "M10.5 8.75V6.75C10.5 5.10626 10.5 4.28439 10.046 3.73121C9.96291 3.62995 9.87005 3.53709 9.76879 3.45398C9.21561 3 8.39374 3 6.75 3C5.10626 3 4.28439 3 3.73121 3.45398C3.62995 3.53709 3.53709 3.62995 3.45398 3.73121C3 4.28439 3 5.10626 3 6.75V8.75C3 10.3937 3 11.2156 3.45398 11.7688C3.53709 11.8701 3.62995 11.9629 3.73121 12.046C4.28439 12.5 5.10626 12.5 6.75 12.5C8.39374 12.5 9.21561 12.5 9.76879 12.046C9.87005 11.9629 9.96291 11.8701 10.046 11.7688C10.5 11.2156 10.5 10.3937 10.5 8.75Z M7.75 15.5H5.75C5.05222 15.5 4.70333 15.5 4.41943 15.5861C3.78023 15.78 3.28002 16.2802 3.08612 16.9194C3 17.2033 3 17.5522 3 18.25C3 18.9478 3 19.2967 3.08612 19.5806C3.28002 20.2198 3.78023 20.72 4.41943 20.9139C4.70333 21 5.05222 21 5.75 21H7.75C8.44778 21 8.79667 21 9.08057 20.9139C9.71977 20.72 10.22 20.2198 10.4139 19.5806C10.5 19.2967 10.5 18.9478 10.5 18.25C10.5 17.5522 10.5 17.2033 10.4139 16.9194C10.22 16.2802 9.71977 15.78 9.08057 15.5861C8.79667 15.5 8.44778 15.5 7.75 15.5Z M21 17.25V15.25C21 13.6063 21 12.7844 20.546 12.2312C20.4629 12.1299 20.3701 12.0371 20.2688 11.954C19.7156 11.5 18.8937 11.5 17.25 11.5C15.6063 11.5 14.7844 11.5 14.2312 11.954C14.1299 12.0371 14.0371 12.1299 13.954 12.2312C13.5 12.7844 13.5 13.6063 13.5 15.25V17.25C13.5 18.8937 13.5 19.7156 13.954 20.2688C14.0371 20.3701 14.1299 20.4629 14.2312 20.546C14.7844 21 15.6063 21 17.25 21C18.8937 21 19.7156 21 20.2688 20.546C20.3701 20.4629 20.4629 20.3701 20.546 20.2688C21 19.7156 21 18.8937 21 17.25Z M18.25 3H16.25C15.5522 3 15.2033 3 14.9194 3.08612C14.2802 3.28002 13.78 3.78023 13.5861 4.41943C13.5 4.70333 13.5 5.05222 13.5 5.75C13.5 6.44778 13.5 6.79667 13.5861 7.08057C13.78 7.71977 14.2802 8.21998 14.9194 8.41388C15.2033 8.5 15.5522 8.5 16.25 8.5H18.25C18.9478 8.5 19.2967 8.5 19.5806 8.41388C20.2198 8.21998 20.72 7.71977 20.9139 7.08057C21 6.79667 21 6.44778 21 5.75C21 5.05222 21 4.70333 20.9139 4.41943C20.72 3.78023 20.2198 3.28002 19.5806 3.08612C19.2967 3 18.9478 3 18.25 3Z", + "fill": false, + "cap": "butt", + "w": 1.5 + }, + "discord": { + "d": "M15.5 17.5C16.5 19 17.3333 19.6667 18 20C19.3333 19.6667 22 18.2 22 15C22 11.8 20.6667 7.33333 20 5.5C18 4.3 15.8333 4 15 4L14.198 5.60393C13.4135 5.28708 12.4058 5.25438 12 5.27763C11.5942 5.25438 10.5865 5.28708 9.80197 5.60393L9 4C8.16667 4 6 4.3 4 5.5C3.33333 7.33333 2 11.8 2 15C2 18.2 4.66667 19.6667 6 20C6.66667 19.6667 7.5 19 8.5 17.5 M17.3652 11.5C17.3652 12.6046 16.5817 13.5 15.6152 13.5C14.6487 13.5 13.8652 12.6046 13.8652 11.5C13.8652 10.3954 14.6487 9.5 15.6152 9.5C16.5817 9.5 17.3652 10.3954 17.3652 11.5Z M10 11.5C10 12.6046 9.2165 13.5 8.25 13.5C7.2835 13.5 6.5 12.6046 6.5 11.5C6.5 10.3954 7.2835 9.5 8.25 9.5C9.2165 9.5 10 10.3954 10 11.5Z M17.5 16.5C16.4022 17.3967 14.3502 18 12 18C9.64981 18 7.59785 17.3967 6.5 16.5", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "edit": { + "d": "M16.9459 3.17305C17.5332 2.58578 17.8268 2.29215 18.1521 2.15173C18.6208 1.94942 19.1521 1.94942 19.6208 2.15173C19.946 2.29215 20.2397 2.58578 20.8269 3.17305C21.4142 3.76032 21.7079 4.05395 21.8483 4.37925C22.0506 4.8479 22.0506 5.37924 21.8483 5.84789C21.7079 6.17319 21.4142 6.46682 20.8269 7.05409L15.8054 12.0757C14.5682 13.3129 13.9496 13.9315 13.1748 14.298C12.4 14.6645 11.5294 14.7504 9.78823 14.9222L9 15L9.07778 14.2118C9.24958 12.4706 9.33549 11.6 9.70201 10.8252C10.0685 10.0504 10.6871 9.43183 11.9243 8.19464L16.9459 3.17305Z M6 15H3.75C2.7835 15 2 15.7835 2 16.75C2 17.7165 2.7835 18.5 3.75 18.5H13.25C14.2165 18.5 15 19.2835 15 20.25C15 21.2165 14.2165 22 13.25 22H11", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "group": { + "d": "M7.5 19.5C7.5 18.5344 7.82853 17.5576 8.63092 17.0204C9.59321 16.3761 10.7524 16 12 16C13.2476 16 14.4068 16.3761 15.3691 17.0204C16.1715 17.5576 16.5 18.5344 16.5 19.5 M9.5 11.0a2.5 2.5 0 1 0 5.0 0a2.5 2.5 0 1 0 -5.0 0 M17.5 11C18.6101 11 19.6415 11.3769 20.4974 12.0224C21.2229 12.5696 21.5 13.4951 21.5 14.4038V14.5 M15.5 6.5a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0 M6.5 11C5.38987 11 4.35846 11.3769 3.50256 12.0224C2.77706 12.5696 2.5 13.4951 2.5 14.4038V14.5 M4.5 6.5a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "heart": { + "d": "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "home": { + "d": "M3 11.9896V14.5C3 17.7998 3 19.4497 4.02513 20.4749C5.05025 21.5 6.70017 21.5 10 21.5H14C17.2998 21.5 18.9497 21.5 19.9749 20.4749C21 19.4497 21 17.7998 21 14.5V11.9896C21 10.3083 21 9.46773 20.6441 8.74005C20.2882 8.01237 19.6247 7.49628 18.2976 6.46411L16.2976 4.90855C14.2331 3.30285 13.2009 2.5 12 2.5C10.7991 2.5 9.76689 3.30285 7.70242 4.90855L5.70241 6.46411C4.37533 7.49628 3.71179 8.01237 3.3559 8.74005C3 9.46773 3 10.3083 3 11.9896Z M15.0002 17C14.2007 17.6224 13.1504 18 12.0002 18C10.8499 18 9.79971 17.6224 9.00018 17", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "infinity": { + "d": "M12 12C12 12 9.26142 17 6.5 17C3.73858 17 2 14.7614 2 12C2 9.23858 3.73858 7 6.5 7C9.26142 7 12 12 12 12ZM12 12C12 12 14.7386 17 17.5 17C20.2614 17 22 14.7614 22 12C22 9.23858 20.2614 7 17.5 7C14.7386 7 12 12 12 12Z", + "fill": false, + "cap": "butt", + "w": 1.5 + }, + "info": { + "d": "M2.0 12.0a10.0 10.0 0 1 0 20.0 0a10.0 10.0 0 1 0 -20.0 0 M12 16V12 M12.125 8.25H12M12.25 8.25C12.25 8.11193 12.1381 8 12 8C11.8619 8 11.75 8.11193 11.75 8.25C11.75 8.38807 11.8619 8.5 12 8.5C12.1381 8.5 12.25 8.38807 12.25 8.25Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "jump-back": { + "d": "M11 6H15.5C17.9853 6 20 8.01472 20 10.5C20 12.9853 17.9853 15 15.5 15H4 M6.99998 12C6.99998 12 4.00001 14.2095 4 15C3.99999 15.7906 7 18 7 18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "library": { + "d": "M2 7C2 5.59987 2 4.8998 2.27248 4.36502C2.51217 3.89462 2.89462 3.51217 3.36502 3.27248C3.8998 3 4.59987 3 6 3C7.40013 3 8.1002 3 8.63498 3.27248C9.10538 3.51217 9.48783 3.89462 9.72752 4.36502C10 4.8998 10 5.59987 10 7V17C10 18.4001 10 19.1002 9.72752 19.635C9.48783 20.1054 9.10538 20.4878 8.63498 20.7275C8.1002 21 7.40013 21 6 21C4.59987 21 3.8998 21 3.36502 20.7275C2.89462 20.4878 2.51217 20.1054 2.27248 19.635C2 19.1002 2 18.4001 2 17V7Z M6.125 17H6M6.25 17C6.25 17.1381 6.13807 17.25 6 17.25C5.86193 17.25 5.75 17.1381 5.75 17C5.75 16.8619 5.86193 16.75 6 16.75C6.13807 16.75 6.25 16.8619 6.25 17Z M17.9062 16.6934H17.7812M18.0312 16.6934C18.0312 16.8314 17.9193 16.9434 17.7812 16.9434C17.6432 16.9434 17.5312 16.8314 17.5312 16.6934C17.5312 16.5553 17.6432 16.4434 17.7812 16.4434C17.9193 16.4434 18.0312 16.5553 18.0312 16.6934Z M2 7H10 M11.4486 8.26843C11.0937 6.93838 10.9163 6.27336 11.0385 5.69599C11.146 5.18812 11.4108 4.72747 11.7951 4.38005C12.2319 3.98508 12.8942 3.80689 14.2187 3.4505C15.5432 3.09412 16.2055 2.91593 16.7804 3.03865C17.2862 3.1466 17.7449 3.41256 18.0909 3.79841C18.4842 4.23706 18.6617 4.90209 19.0166 6.23213L21.5514 15.7316C21.9063 17.0616 22.0837 17.7266 21.9615 18.304C21.854 18.8119 21.5892 19.2725 21.2049 19.62C20.7681 20.0149 20.1058 20.1931 18.7813 20.5495C17.4568 20.9059 16.7945 21.0841 16.2196 20.9614C15.7138 20.8534 15.2551 20.5874 14.9091 20.2016C14.5158 19.7629 14.3383 19.0979 13.9834 17.7679L11.4486 8.26843Z M12 8.00019L18.5001 6", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "link": { + "d": "M9.14339 10.691L9.35031 10.4841C11.329 8.50532 14.5372 8.50532 16.5159 10.4841C18.4947 12.4628 18.4947 15.671 16.5159 17.6497L13.6497 20.5159C11.671 22.4947 8.46279 22.4947 6.48405 20.5159C4.50532 18.5372 4.50532 15.329 6.48405 13.3503L6.9484 12.886 M17.0516 11.114L17.5159 10.6497C19.4947 8.67095 19.4947 5.46279 17.5159 3.48405C15.5372 1.50532 12.329 1.50532 10.3503 3.48405L7.48405 6.35031C5.50532 8.32904 5.50532 11.5372 7.48405 13.5159C9.46279 15.4947 12.671 15.4947 14.6497 13.5159L14.8566 13.309", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "loading": { + "d": "M12 3V6 M12 18V21 M21 12L18 12 M6 12L3 12 M18.3635 5.63672L16.2422 7.75804 M7.75804 16.2422L5.63672 18.3635 M18.3635 18.3635L16.2422 16.2422 M7.75804 7.75804L5.63672 5.63672", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "mic": { + "d": "M17 7V11C17 13.7614 14.7614 16 12 16C9.23858 16 7 13.7614 7 11V7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7Z M17 7H14M17 11H14 M20 11C20 15.4183 16.4183 19 12 19M12 19C7.58172 19 4 15.4183 4 11M12 19V22M12 22H15M12 22H9", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "minimize": { + "d": "M11.4333 16.0659L8.6912 15.9658C8.28365 15.951 7.96094 15.6163 7.96094 15.2084L7.96094 12.5936M13.4609 10.5659L8.41716 15.5843 M22 7C22 8.8856 22 9.8284 21.4142 10.4142C20.8284 11 19.8856 11 18 11H17C15.1144 11 14.1716 11 13.5858 10.4142C13 9.8284 13 8.8856 13 7L13 6C13 4.1144 13 3.1716 13.5858 2.5858C14.1716 2 15.1144 2 17 2L18 2C19.8856 2 20.8284 2 21.4142 2.5858C22 3.1716 22 4.1144 22 6V7Z M22 15.5V13.5M10 22H14M2 10L2 14M10.5 2L8.5 2M21.9401 18.5C21.7861 19.5656 21.4865 20.321 20.9037 20.9038C20.321 21.4865 19.5656 21.7861 18.5 21.9401M5.5 21.9401C4.4344 21.7861 3.679 21.4865 3.0963 20.9037C2.5135 20.321 2.2139 19.5656 2.0599 18.5M2.0599 5.5C2.2139 4.4344 2.5135 3.679 3.0963 3.0963C3.679 2.5135 4.4344 2.2139 5.5 2.0599", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "music": { + "d": "M7 9.5C7 10.8807 5.88071 12 4.5 12C3.11929 12 2 10.8807 2 9.5C2 8.11929 3.11929 7 4.5 7C5.88071 7 7 8.11929 7 9.5ZM7 9.5V2C7.33333 2.5 7.6 4.6 10 5 M8.0 19.5a2.5 2.5 0 1 0 5.0 0a2.5 2.5 0 1 0 -5.0 0 M18.0 18.0a2.0 2.0 0 1 0 4.0 0a2.0 2.0 0 1 0 -4.0 0 M13 19.5L13 11C13 10.09 13 9.63502 13.2466 9.35248C13.4932 9.06993 13.9938 9.00163 14.9949 8.86504C18.0085 8.45385 20.2013 7.19797 21.3696 6.42937C21.6498 6.24509 21.7898 6.15295 21.8949 6.20961C22 6.26627 22 6.43179 22 6.76283V17.9259 M13 13C17.8 13 21 10.6667 22 10", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "next": { + "d": "M15.9351 12.6258C15.6807 13.8374 14.327 14.7077 11.6198 16.4481C8.67528 18.3411 7.20303 19.2876 6.01052 18.9229C5.60662 18.7994 5.23463 18.5823 4.92227 18.2876C4 17.4178 4 15.6118 4 12C4 8.38816 4 6.58224 4.92227 5.71235C5.23463 5.41773 5.60662 5.20057 6.01052 5.07707C7.20304 4.71243 8.67528 5.6589 11.6198 7.55186C14.327 9.29233 15.6807 10.1626 15.9351 11.3742C16.0216 11.7865 16.0216 12.2135 15.9351 12.6258Z M20 5V19", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "on-repeat": { + "d": "M3 4.5H21 M3 11.5H8 M3 18.5H8 M12.7578 17.5C13.565 18.706 14.9398 19.5 16.5 19.5C18.9853 19.5 21 17.4853 21 15C21 12.5147 18.9853 10.5 16.5 10.5C14.8075 10.5 13.3332 11.4344 12.5649 12.8154M12 9.5V10.5C12 11.9142 12 12.6213 12.4393 13.0607C12.8787 13.5 13.5858 13.5 15 13.5H16", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "pause": { + "d": "M4 7C4 5.58579 4 4.87868 4.43934 4.43934C4.87868 4 5.58579 4 7 4C8.41421 4 9.12132 4 9.56066 4.43934C10 4.87868 10 5.58579 10 7V17C10 18.4142 10 19.1213 9.56066 19.5607C9.12132 20 8.41421 20 7 20C5.58579 20 4.87868 20 4.43934 19.5607C4 19.1213 4 18.4142 4 17V7Z M14 7C14 5.58579 14 4.87868 14.4393 4.43934C14.8787 4 15.5858 4 17 4C18.4142 4 19.1213 4 19.5607 4.43934C20 4.87868 20 5.58579 20 7V17C20 18.4142 20 19.1213 19.5607 19.5607C19.1213 20 18.4142 20 17 20C15.5858 20 14.8787 20 14.4393 19.5607C14 19.1213 14 18.4142 14 17V7Z", + "fill": false, + "cap": "butt", + "w": 1.5 + }, + "pin": { + "d": "M3 21L8 16 M13.2585 18.8714C9.51516 18.0215 5.97844 14.4848 5.12853 10.7415C4.99399 10.1489 4.92672 9.85266 5.12161 9.37197C5.3165 8.89129 5.55457 8.74255 6.03071 8.44509C7.10705 7.77265 8.27254 7.55888 9.48209 7.66586C11.1793 7.81598 12.0279 7.89104 12.4512 7.67048C12.8746 7.44991 13.1622 6.93417 13.7376 5.90269L14.4664 4.59604C14.9465 3.73528 15.1866 3.3049 15.7513 3.10202C16.316 2.89913 16.6558 3.02199 17.3355 3.26771C18.9249 3.84236 20.1576 5.07505 20.7323 6.66449C20.978 7.34417 21.1009 7.68401 20.898 8.2487C20.6951 8.8134 20.2647 9.05346 19.4039 9.53358L18.0672 10.2792C17.0376 10.8534 16.5229 11.1406 16.3024 11.568C16.0819 11.9955 16.162 12.8256 16.3221 14.4859C16.4399 15.7068 16.2369 16.88 15.5555 17.9697C15.2577 18.4458 15.1088 18.6839 14.6283 18.8786C14.1477 19.0733 13.8513 19.006 13.2585 18.8714Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "play": { + "d": "M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z", + "fill": false, + "cap": "butt", + "w": 1.5 + }, + "playlist": { + "d": "M2.50012 7.5H21.5001 M17.0001 2.5L14.0001 7.5 M10.0001 2.5L7.00012 7.5 M2.5 12C2.5 7.52166 2.5 5.28249 3.89124 3.89124C5.28249 2.5 7.52166 2.5 12 2.5C16.4783 2.5 18.7175 2.5 20.1088 3.89124C21.5 5.28249 21.5 7.52166 21.5 12C21.5 16.4783 21.5 18.7175 20.1088 20.1088C18.7175 21.5 16.4783 21.5 12 21.5C7.52166 21.5 5.28249 21.5 3.89124 20.1088C2.5 18.7175 2.5 16.4783 2.5 12Z M14.9531 14.8948C14.8016 15.5215 14.0857 15.9644 12.6539 16.8502C11.2697 17.7064 10.5777 18.1346 10.0199 17.9625C9.78934 17.8913 9.57925 17.7562 9.40982 17.57C9 17.1198 9 16.2465 9 14.5C9 12.7535 9 11.8802 9.40982 11.4299C9.57925 11.2438 9.78934 11.1087 10.0199 11.0375C10.5777 10.8654 11.2697 11.2936 12.6539 12.1498C14.0857 13.0356 14.8016 13.4785 14.9531 14.1052C15.0156 14.3639 15.0156 14.6361 14.9531 14.8948Z", + "fill": false, + "cap": "butt", + "w": 1.5 + }, + "previous": { + "d": "M8.06492 12.6258C8.31931 13.8374 9.67295 14.7077 12.3802 16.4481C15.3247 18.3411 16.797 19.2876 17.9895 18.9229C18.3934 18.7994 18.7654 18.5823 19.0777 18.2876C20 17.4178 20 15.6118 20 12C20 8.38816 20 6.58224 19.0777 5.71235C18.7654 5.41773 18.3934 5.20057 17.9895 5.07707C16.797 4.71243 15.3247 5.6589 12.3802 7.55186C9.67295 9.29233 8.31931 10.1626 8.06492 11.3742C7.97836 11.7865 7.97836 12.2135 8.06492 12.6258Z M4 4L4 20", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "queue": { + "d": "M21 14C21 15.4001 21 16.1002 20.7275 16.635C20.4878 17.1054 20.1054 17.4878 19.635 17.7275C19.1002 18 18.4001 18 17 18H7C5.59987 18 4.8998 18 4.36502 17.7275C3.89462 17.4878 3.51217 17.1054 3.27248 16.635C3 16.1002 3 15.4001 3 14 M6 14H18 M6 10H18 M6 6H18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "radio": { + "d": "M2 13C2 9.70017 2 8.05025 2.87868 7.02513C3.75736 6 5.17157 6 8 6H16C18.8284 6 20.2426 6 21.1213 7.02513C22 8.05025 22 9.70017 22 13C22 16.2998 22 17.9497 21.1213 18.9749C20.2426 20 18.8284 20 16 20H8C5.17157 20 3.75736 20 2.87868 18.9749C2 17.9497 2 16.2998 2 13Z M6 20V22M18 20V22 M5 6L19 2 M9 11H6 M12.0 13.0a3.0 3.0 0 1 0 6.0 0a3.0 3.0 0 1 0 -6.0 0 M7.125 15H7M7.25 15C7.25 15.1381 7.13807 15.25 7 15.25C6.86193 15.25 6.75 15.1381 6.75 15C6.75 14.8619 6.86193 14.75 7 14.75C7.13807 14.75 7.25 14.8619 7.25 15Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "rail-collapse": { + "d": "M2.5 12C2.5 7.52166 2.5 5.28249 3.89124 3.89124C5.28249 2.5 7.52166 2.5 12 2.5C16.4783 2.5 18.7175 2.5 20.1088 3.89124C21.5 5.28249 21.5 7.52166 21.5 12C21.5 16.4783 21.5 18.7175 20.1088 20.1088C18.7175 21.5 16.4783 21.5 12 21.5C7.52166 21.5 5.28249 21.5 3.89124 20.1088C2.5 18.7175 2.5 16.4783 2.5 12Z M14 7C14 7 10 10.6824 10 12C9.99999 13.3176 14 17 14 17", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "rail-expand": { + "d": "M2.5 12C2.5 7.52166 2.5 5.28249 3.89124 3.89124C5.28249 2.5 7.52166 2.5 12 2.5C16.4783 2.5 18.7175 2.5 20.1088 3.89124C21.5 5.28249 21.5 7.52166 21.5 12C21.5 16.4783 21.5 18.7175 20.1088 20.1088C18.7175 21.5 16.4783 21.5 12 21.5C7.52166 21.5 5.28249 21.5 3.89124 20.1088C2.5 18.7175 2.5 16.4783 2.5 12Z M10 7C10 7 14 10.6824 14 12C14 13.3176 10 17 10 17", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "repeat-one": { + "d": "M16.3884 3L17.3913 3.97574C17.8393 4.41165 18.0633 4.62961 17.9844 4.81481C17.9056 5 17.5888 5 16.9552 5H9.19422C5.22096 5 2 8.13401 2 12C2 13.4872 2.47668 14.8662 3.2895 16 M7.61156 21L6.60875 20.0243C6.16074 19.5883 5.93673 19.3704 6.01557 19.1852C6.09441 19 6.4112 19 7.04478 19H14.8058C18.779 19 22 15.866 22 12C22 10.5128 21.5233 9.13383 20.7105 8 M13 15L13 9.31633C13 9.05613 12.7178 8.90761 12.52 9.06373L11 10.2636", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "repeat": { + "d": "M16.3884 3L17.3913 3.97574C17.8393 4.41165 18.0633 4.62961 17.9844 4.81481C17.9056 5 17.5888 5 16.9552 5H9.19422C5.22096 5 2 8.13401 2 12C2 13.4872 2.47668 14.8662 3.2895 16 M7.61156 21L6.60875 20.0243C6.16074 19.5883 5.93673 19.3704 6.01557 19.1852C6.09441 19 6.4112 19 7.04478 19H14.8058C18.779 19 22 15.866 22 12C22 10.5128 21.5233 9.13383 20.7105 8", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "search": { + "d": "M17 17L21 21 M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "settings": { + "d": "M21.3175 7.14139L20.8239 6.28479C20.4506 5.63696 20.264 5.31305 19.9464 5.18388C19.6288 5.05472 19.2696 5.15664 18.5513 5.36048L17.3311 5.70418C16.8725 5.80994 16.3913 5.74994 15.9726 5.53479L15.6357 5.34042C15.2766 5.11043 15.0004 4.77133 14.8475 4.37274L14.5136 3.37536C14.294 2.71534 14.1842 2.38533 13.9228 2.19657C13.6615 2.00781 13.3143 2.00781 12.6199 2.00781H11.5051C10.8108 2.00781 10.4636 2.00781 10.2022 2.19657C9.94085 2.38533 9.83106 2.71534 9.61149 3.37536L9.27753 4.37274C9.12465 4.77133 8.84845 5.11043 8.48937 5.34042L8.15249 5.53479C7.73374 5.74994 7.25259 5.80994 6.79398 5.70418L5.57375 5.36048C4.85541 5.15664 4.49625 5.05472 4.17867 5.18388C3.86109 5.31305 3.67445 5.63696 3.30115 6.28479L2.80757 7.14139C2.45766 7.74864 2.2827 8.05227 2.31666 8.37549C2.35061 8.69871 2.58483 8.95918 3.05326 9.48012L4.0843 10.6328C4.3363 10.9518 4.51521 11.5078 4.51521 12.0077C4.51521 12.5078 4.33636 13.0636 4.08433 13.3827L3.05326 14.5354C2.58483 15.0564 2.35062 15.3168 2.31666 15.6401C2.2827 15.9633 2.45766 16.2669 2.80757 16.8741L3.30114 17.7307C3.67443 18.3785 3.86109 18.7025 4.17867 18.8316C4.49625 18.9608 4.85542 18.8589 5.57377 18.655L6.79394 18.3113C7.25263 18.2055 7.73387 18.2656 8.15267 18.4808L8.4895 18.6752C8.84851 18.9052 9.12464 19.2442 9.2775 19.6428L9.61149 20.6403C9.83106 21.3003 9.94085 21.6303 10.2022 21.8191C10.4636 22.0078 10.8108 22.0078 11.5051 22.0078H12.6199C13.3143 22.0078 13.6615 22.0078 13.9228 21.8191C14.1842 21.6303 14.294 21.3003 14.5136 20.6403L14.8476 19.6428C15.0004 19.2442 15.2765 18.9052 15.6356 18.6752L15.9724 18.4808C16.3912 18.2656 16.8724 18.2055 17.3311 18.3113L18.5513 18.655C19.2696 18.8589 19.6288 18.9608 19.9464 18.8316C20.264 18.7025 20.4506 18.3785 20.8239 17.7307L21.3175 16.8741C21.6674 16.2669 21.8423 15.9633 21.8084 15.6401C21.7744 15.3168 21.5402 15.0564 21.0718 14.5354L20.0407 13.3827C19.7887 13.0636 19.6098 12.5078 19.6098 12.0077C19.6098 11.5078 19.7888 10.9518 20.0407 10.6328L21.0718 9.48012C21.5402 8.95918 21.7744 8.69871 21.8084 8.37549C21.8423 8.05227 21.6674 7.74864 21.3175 7.14139Z M15.5195 12C15.5195 13.933 13.9525 15.5 12.0195 15.5C10.0865 15.5 8.51953 13.933 8.51953 12C8.51953 10.067 10.0865 8.5 12.0195 8.5C13.9525 8.5 15.5195 10.067 15.5195 12Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "shuffle": { + "d": "M19.5576 4L20.4551 4.97574C20.8561 5.41165 21.0566 5.62961 20.9861 5.81481C20.9155 6 20.632 6 20.0649 6C18.7956 6 17.2771 5.79493 16.1111 6.4733C15.3903 6.89272 14.8883 7.62517 14.0392 9M3 18H4.58082C6.50873 18 7.47269 18 8.2862 17.5267C9.00708 17.1073 9.50904 16.3748 10.3582 15 M19.5576 20L20.4551 19.0243C20.8561 18.5883 21.0566 18.3704 20.9861 18.1852C20.9155 18 20.632 18 20.0649 18C18.7956 18 17.2771 18.2051 16.1111 17.5267C15.2976 17.0534 14.7629 16.1815 13.6935 14.4376L10.7038 9.5624C9.63441 7.81853 9.0997 6.9466 8.2862 6.4733C7.47269 6 6.50873 6 4.58082 6H3", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "user": { + "d": "M17 8.5C17 5.73858 14.7614 3.5 12 3.5C9.23858 3.5 7 5.73858 7 8.5C7 11.2614 9.23858 13.5 12 13.5C14.7614 13.5 17 11.2614 17 8.5Z M19 20.5C19 16.634 15.866 13.5 12 13.5C8.13401 13.5 5 16.634 5 20.5", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "volume-mute": { + "d": "M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z M18 10L22 14M18 14L22 10", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "volume": { + "d": "M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z M17 9C17.6254 9.81968 18 10.8634 18 12C18 13.1366 17.6254 14.1803 17 15 M20 7C21.2508 8.36613 22 10.1057 22 12C22 13.8943 21.2508 15.6339 20 17", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "more": { + "d": "M12 12h.01 M18.5 12h.01 M5.5 12h.01", + "fill": false, + "cap": "round", + "w": 2.5 + }, + "sound": { + "d": "M4 6h8 M16 6h4 M4 12h3 M11 12h9 M4 18h10 M18 18h2 M12 4v4 M7 10v4 M14 16v4", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "panel": { + "d": "M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 18.5z M15 3v18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "sidebar": { + "d": "M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 18.5z M9 3v18", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "expand": { + "d": "M4 9V6a2 2 0 0 1 2-2h3 M20 9V6a2 2 0 0 0-2-2h-3 M4 15v3a2 2 0 0 0 2 2h3 M20 15v3a2 2 0 0 1-2 2h-3", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "chevron-down": { + "d": "M6 9l6 6 6-6", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "chevron-right": { + "d": "M9 6l6 6-6 6", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "sort": { + "d": "M7 4v16 M3 8l4-4 4 4 M17 20V4 M13 16l4 4 4-4", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "youtube-music": { + "d": "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M12 7.5a4.5 4.5 0 1 0 0 9a4.5 4.5 0 1 0 0-9z M10.5 9.8v4.4l3.6-2.2z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "spotify": { + "d": "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M7 9.4c3.4-1 7.2-0.7 10.2 1 M7.6 12.3c2.8-0.8 5.9-0.5 8.4 0.9 M8.2 15c2.2-0.6 4.6-0.4 6.6 0.7", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "soundcloud": { + "d": "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "folder": { + "d": "M3 7C3 5.89543 3.89543 5 5 5H9L11 7H19C20.1046 7 21 7.89543 21 9V17C21 18.1046 20.1046 19 19 19H5C3.89543 19 3 18.1046 3 17V7Z", + "fill": false, + "cap": "round", + "w": 1.5 + }, + "trash": { + "d": "M4 7H20 M9 7V5C9 4.44772 9.44772 4 10 4H14C14.5523 4 15 4.44772 15 5V7 M6 7.5L6.79 19.1C6.85 20.16 7.73 21 8.79 21H15.21C16.27 21 17.15 20.16 17.21 19.1L18 7.5 M10 11.5V16.5 M14 11.5V16.5", + "fill": false, + "cap": "round", + "w": 1.5 + } +}; + +function glyph(name) { return glyphs[name] || null; } diff --git a/client/lib/ids.js b/client/lib/ids.js new file mode 100644 index 0000000..d394013 --- /dev/null +++ b/client/lib/ids.js @@ -0,0 +1,43 @@ +.pragma library + +// Id prefix tests, ported from ui/src/lib/api.ts. A song/album/artist on disk carries a LOCAL* +// prefix and a live radio station carries the RYOTUNES_RADIO prefix; neither has a YouTube rating, +// so the like control hides for them exactly as the Svelte PlayerBar does. The three smart-playlist +// ids draw an icon cover instead of artwork. Kept in a pure module so both the singletons and the +// view components share one source of truth. +var LOCAL_SONG = "LOCAL:"; +var LOCAL_ALBUM = "LOCALALBUM:"; +var LOCAL_ARTIST = "LOCALARTIST:"; +var RADIO = "RYOTUNES_RADIO:"; +var ON_REPEAT = "RYOTUNES_ON_REPEAT"; +var RECENTLY_PLAYED = "RYOTUNES_RECENTLY_PLAYED"; +var REDISCOVER = "RYOTUNES_REDISCOVER"; + +function isLocalId(id) { + return !!id && (id.indexOf(LOCAL_SONG) === 0 || id.indexOf(LOCAL_ALBUM) === 0 || id.indexOf(LOCAL_ARTIST) === 0); +} +function isRadioId(id) { + return !!id && id.indexOf(RADIO) === 0; +} +function isOnRepeatId(id) { + return id === ON_REPEAT; +} +function isSmartPlaylistId(id) { + return id === ON_REPEAT || id === RECENTLY_PLAYED || id === REDISCOVER; +} + +// The daemon's ids are namespaced across catalogues; keep Home history and +// recommendations on the selected provider without mistaking SoundCloud for YouTube. +function providerOf(id) { + if (typeof id !== "string" || !id) + return ""; + if (isLocalId(id)) + return "local"; + if (isRadioId(id)) + return "radio"; + if (id.indexOf("spotify:") === 0) + return "spotify"; + if (id.indexOf("sc:") === 0) + return "soundcloud"; + return "youtube"; +} diff --git a/client/lib/personal.js b/client/lib/personal.js new file mode 100644 index 0000000..c632056 --- /dev/null +++ b/client/lib/personal.js @@ -0,0 +1,322 @@ +.pragma library + +// The personal store's pure reducers, a port of ui/src/lib/personal.ts. Same shapes, same rules, +// no storage or network: Personal.qml holds the live blob and persists it through the daemon, this +// module only transforms it. Kept pure so tst_personal.qml exercises it exactly as the Svelte +// build's personal.check.ts does. The daemon serialises this blob under `personal_json`. + +var MAX_PICKS = 18; +var MAX_PINS = 3; +var MAX_RECENT = 100; +var MAX_ARTISTS = 100; +var ON_REPEAT_ID = "RYOTUNES_ON_REPEAT"; + +function normalizeLegacyId(id) { + return id.slice(-10) === "_ON_REPEAT" ? ON_REPEAT_ID : id; +} + +function empty() { + return { + picks: [], + saved: [], + pins: [], + recent: {}, + artists: {}, + dismissedSeeds: [], + home: { order: [], hidden: [], seen: [] } + }; +} + +// Tolerant parse of a persisted blob — a corrupt or older shape degrades to empty, never throws. +function hydrate(raw) { + var base = empty(); + if (!raw || typeof raw !== "object") + return base; + var o = raw; + if (Array.isArray(o.picks)) { + // `manual: false` marks a tile from the old auto-seeding build; those are dropped rather + // than inherited forever. Today's seedPick writes no `manual` flag. + base.picks = o.picks + .filter(function (p) { return p && typeof p.id === "string" && p.manual !== false; }) + .map(function (p) { + var t = {}; + for (var k in p) t[k] = p[k]; + t.id = normalizeLegacyId(p.id); + return t; + }); + } + if (Array.isArray(o.saved)) { + base.saved = o.saved + .filter(function (s) { return s && typeof s.id === "string" && typeof s.kind === "string"; }) + .map(function (s) { + var t = {}; + for (var k in s) t[k] = s[k]; + t.id = normalizeLegacyId(s.id); + return t; + }); + } + if (Array.isArray(o.pins)) { + base.pins = o.pins + .filter(function (p) { return typeof p === "string"; }) + .map(normalizeLegacyId) + .slice(0, MAX_PINS); + } + if (o.recent && typeof o.recent === "object") + base.recent = o.recent; + if (o.artists && typeof o.artists === "object") + base.artists = o.artists; + if (Array.isArray(o.dismissedSeeds)) { + base.dismissedSeeds = o.dismissedSeeds + .filter(function (id) { return typeof id === "string"; }) + .map(normalizeLegacyId); + } + if (o.home && typeof o.home === "object") { + var keys = function (v) { + return Array.isArray(v) ? v.filter(function (k) { return typeof k === "string"; }) : []; + }; + base.home = { order: keys(o.home.order), hidden: keys(o.home.hidden), seen: keys(o.home.seen) }; + // '@familiar' shipped after some users saved an arrangement; slot it where the code puts it, + // once, so an unranked key doesn't sink to the bottom of the feed. + if (base.home.order.length && base.home.order.indexOf("@familiar") < 0) { + var at = base.home.order.indexOf("@recent"); + base.home.order.splice(at < 0 ? base.home.order.length : at + 1, 0, "@familiar"); + } + } + return base; +} + +// --- Shortcuts grid ----------------------------------------------------------------------------- + +function withStamp(item, now) { + var t = {}; + for (var k in item) t[k] = item[k]; + t.lastUsedAt = now; + return t; +} + +// Over capacity: drop the tile gone longest without a play or open. +function evictStalest(p) { + while (p.picks.length > MAX_PICKS) { + var stalest = p.picks[0]; + for (var i = 1; i < p.picks.length; i++) + if (p.picks[i].lastUsedAt < stalest.lastUsedAt) + stalest = p.picks[i]; + p.picks = p.picks.filter(function (x) { return x !== stalest; }); + } +} + +// Append. Returns false when it was already on the grid (its recency is refreshed instead). +function addPick(p, item, now) { + for (var i = 0; i < p.picks.length; i++) { + if (p.picks[i].id === item.id) { + p.picks[i].lastUsedAt = now; + return false; + } + } + p.picks.push(withStamp(item, now)); + evictStalest(p); + return true; +} + +// A tile the app suggests rather than one the user added. Refused if it is already on the grid, if +// the user has ever removed it, or if the grid is full (a suggestion never evicts a hand-picked +// one). Returns whether the grid changed. +function seedPick(p, item, now) { + if (p.picks.length >= MAX_PICKS) + return false; + if (p.dismissedSeeds.indexOf(item.id) >= 0) + return false; + for (var i = 0; i < p.picks.length; i++) + if (p.picks[i].id === item.id) + return false; + p.picks.push(withStamp(item, now)); + return true; +} + +function removePick(p, id) { + p.picks = p.picks.filter(function (x) { return x.id !== id; }); + // Every removal is remembered so seedPick won't suggest it again; addPick ignores the list, so a + // hand-added tile is unaffected. + if (p.dismissedSeeds.indexOf(id) < 0) + p.dismissedSeeds.push(id); +} + +// Mark a tile as used (played or clicked). Returns whether anything changed — most calls come from +// cards that aren't on the grid, so the caller can skip a pointless write. +function touchPick(p, id, now) { + for (var i = 0; i < p.picks.length; i++) { + if (p.picks[i].id === id) { + p.picks[i].lastUsedAt = now; + return true; + } + } + return false; +} + +// --- Sidebar pins ------------------------------------------------------------------------------- + +function togglePin(p, id) { + if (p.pins.indexOf(id) >= 0) { + p.pins = p.pins.filter(function (x) { return x !== id; }); + return "unpinned"; + } + if (p.pins.length >= MAX_PINS) + return "full"; + p.pins.push(id); + return "pinned"; +} + +// --- Home arrangement --------------------------------------------------------------------------- + +// Home's sections in the order the user put them, hidden ones included. A section that has no rank +// (one that arrived after the last save) sorts to the end, keeping the feed's own order among peers. +// Sorting only, never filtering — hiddenSections is the other half. +function arrangeSections(sections, p) { + var ranks = {}; + for (var i = 0; i < p.home.order.length; i++) + ranks[p.home.order[i]] = i; + var MAXR = 9007199254740991; // Number.MAX_SAFE_INTEGER; a finite sentinel, not Infinity + var rank = function (key) { return (key in ranks) ? ranks[key] : MAXR; }; + // Decorate-sort-undecorate for a stable sort: equal ranks keep arrival order. + return sections + .map(function (s, i) { return { s: s, i: i }; }) + .sort(function (a, b) { return (rank(a.s.key) - rank(b.s.key)) || (a.i - b.i); }) + .map(function (e) { return e.s; }); +} + +function hiddenSections(p) { + return p.home.hidden.slice(); +} + +// --- Recency + artist counts -------------------------------------------------------------------- + +// Record that a song, playlist, album or artist was played (songs from a genuine now-playing, +// the rest from a play-from-source). The entry is a BrowseItem clone stamped with `at`. +function noteRecent(p, item, now) { + var entry = {}; + for (var k in item) entry[k] = item[k]; + entry.at = now; + p.recent[item.id] = entry; + var ids = Object.keys(p.recent); + if (ids.length > MAX_RECENT) { + var recent = p.recent; + ids.sort(function (a, b) { return recent[b].at - recent[a].at; }); + for (var i = MAX_RECENT; i < ids.length; i++) + delete p.recent[ids[i]]; + } +} + +// The most recently played songs and played-from playlists/albums/artists, newest first. +function recentItems(p, n) { + var recent = p.recent; + var vals = Object.keys(recent).map(function (id) { return recent[id]; }); + vals.sort(function (a, b) { return b.at - a.at; }); + return vals.slice(0, n === undefined ? 12 : n); +} + +// --- account sign-out: forget only what that session can reopen --------------------------------- +// Splitting the purge by provider is deliberate: signing out of YouTube must not drop a Spotify +// recent (or the reverse), and neither touches songs — a public video/track still plays signed out. + +// Recents only a signed-in YouTube session can reopen: Liked Music (LM / VLLM) and the account's +// own library playlists (their subtitle reads "Your …"). Public playlists (a shared link, a chart) +// and everything from other providers stay. Returns how many entries were dropped. +function forgetYouTubeRecents(p) { + var n = 0; + for (var id in p.recent) { + // A Spotify recent is never a YouTube one, even if its subtitle happens to read "Your …". + if (id.indexOf("spotify:") === 0) + continue; + var r = p.recent[id]; + if (id === "LM" || id === "VLLM" + || (r && r.kind === "playlist" && String(r.subtitle || "").indexOf("Your") === 0)) { + delete p.recent[id]; + n++; + } + } + return n; +} + +// Recents only a signed-in Spotify session can reopen: every provider-qualified spotify: id. +// Returns how many entries were dropped. +function forgetSpotifyRecents(p) { + var n = 0; + for (var id in p.recent) { + if (id.indexOf("spotify:") === 0) { + delete p.recent[id]; + n++; + } + } + return n; +} + +// The lead artist out of a joined credit string — a usable search seed, unlike the whole list. +function firstArtist(artists) { + return artists.split(/[,&•]|\sfeat\.?\s|\sft\.?\s/i)[0].trim(); +} + +function noteArtist(p, key, name, now) { + var cur = p.artists[key]; + if (cur) { + cur.count++; + if (name) cur.name = name; + if (now !== undefined) cur.lastPlayedAt = now; + } else { + p.artists[key] = (now !== undefined) + ? { name: name, count: 1, lastPlayedAt: now } + : { name: name, count: 1 }; + } + var keys = Object.keys(p.artists); + if (keys.length > MAX_ARTISTS) { + var artists = p.artists; + keys.sort(function (a, b) { return artists[b].count - artists[a].count; }); + for (var i = MAX_ARTISTS; i < keys.length; i++) + delete p.artists[keys[i]]; + } +} + +// Top artists by play count that carry a real channel id. Names-only keys (a song whose artist was +// not linked) are dropped: without an id there is no artist page to look up or open. +function topArtistIds(p, n) { + var artists = p.artists; + return Object.keys(artists) + .filter(function (id) { return id.slice(0, 2) === "UC"; }) + .sort(function (a, b) { return artists[b].count - artists[a].count; }) + .slice(0, n === undefined ? 7 : n); +} + +// The user's most-played artist names — the seed for the community shelf. +function topArtists(p, n) { + var artists = p.artists; + return Object.keys(artists) + .map(function (id) { return artists[id]; }) + .filter(function (a) { return a.name; }) + .sort(function (a, b) { return b.count - a.count; }) + .slice(0, n === undefined ? 3 : n) + .map(function (a) { return a.name; }); +} + +// Structural equality, independent of object key order. The daemon round-trips a saved blob +// through serde before echoing it back on `personal-changed`, and that can reorder keys, so a byte +// comparison of the echo against the blob we sent would spuriously differ. Personal blobs hold only +// JSON scalars, arrays and plain objects — no functions, dates or cycles — so a plain recursion is +// enough. Used to recognise (and drop) our own save echo. +function deepEqual(a, b) { + if (a === b) return true; + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; + var aArr = Array.isArray(a), bArr = Array.isArray(b); + if (aArr !== bArr) return false; + if (aArr) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) + if (!deepEqual(a[i], b[i])) return false; + return true; + } + var ak = Object.keys(a), bk = Object.keys(b); + if (ak.length !== bk.length) return false; + for (var j = 0; j < ak.length; j++) { + var k = ak[j]; + if (!b.hasOwnProperty(k) || !deepEqual(a[k], b[k])) return false; + } + return true; +} diff --git a/client/lib/playback.js b/client/lib/playback.js new file mode 100644 index 0000000..a59badd --- /dev/null +++ b/client/lib/playback.js @@ -0,0 +1,188 @@ +.pragma library + +// The daemon-event -> state reducer, ported from ui/src/lib/player.svelte.ts (initApp, lines +// ~916-1009) and setPlaybackPosition. It lives in a pure module so the QtTest case can exercise it +// without loading Quickshell (Playback imports Daemon, which imports Quickshell). Playback.qml holds +// the reactive state and calls applyEvent(root, name, data) from its Daemon.event handler; the same +// function mutates a plain object in the test. Side effects the daemon surfaces as toasts/errors are +// returned as a descriptor (or null) for the caller to route, since a JS module cannot emit signals. + +// "2:53" -> 173. Undefined for a missing or malformed duration string. +function durationToSeconds(d) { + if (!d) return undefined; + var parts = String(d).split(":").map(Number); + if (!parts.length || parts.some(function (n) { return isNaN(n); })) return undefined; + return parts.reduce(function (a, b) { return a * 60 + b; }, 0); +} + +// mpv's authoritative transport sample, clamped to [0, duration]. +function clampPosition(duration, position) { + var max = duration > 0 ? duration : Infinity; + return Math.max(0, Math.min(max, isFinite(position) ? position : 0)); +} + +// True while the seek thumb is held: a real number pins the shown position, NaN means released. +function dragging(v) { + return typeof v === "number" && !isNaN(v); +} + +// Apply one daemon event to `s`. Returns a { toast, kind } descriptor for the events that surface a +// message, else null. Event payload shapes are the daemon's, verified against ryotunesd: position +// and duration wrap their value in an object; playback-state and volume are bare scalars. +function applyEvent(s, name, data) { + switch (name) { + case "now-playing": { + var n = data; + var trackChanged = !s.now || s.now.videoId !== n.videoId; + s.now = n; + if (trackChanged) { + var d = durationToSeconds(n.duration); + s.duration = (d === undefined) ? 0 : d; + s.position = clampPosition(s.duration, 0); + } + s.pendingVideoId = null; + s.lastError = null; + // Initial row snapshot; a backend rating refresh may correct it. + s.rating = n.rating || "indifferent"; + return null; + } + case "rating": + if (s.now && s.now.videoId === data.videoId) s.rating = data.rating; + return null; + case "queue-changed": + s.queue = data; + return null; + case "queue-index": { + // The items did not change, so keep the array already held and patch the rest. Splice the + // playing row back in: start_current backfills its duration/artists after the stream + // resolves, and that repair rides on this event rather than a whole new queue. + var items = (s.queue && s.queue.items) ? s.queue.items : []; + if (data.current && items[data.currentIndex] !== undefined) items[data.currentIndex] = data.current; + s.queue = { + items: items, + currentIndex: data.currentIndex, + playedFrom: data.playedFrom, + shuffle: data.shuffle, + repeat: data.repeat, + sourceName: data.sourceName + }; + return null; + } + case "position": + // Not while our own seek drag is in flight: the pointer already moved past this sample. + if (!dragging(s.seekDrag)) s.position = clampPosition(s.duration, data.position); + return null; + case "duration": + s.duration = data.duration; + return null; + case "playback-state": + s.paused = (data === "paused"); + return null; + case "stop-after-current": + s.stopAfterCurrent = data; + return null; + case "volume": + // Not while the volume slider drags: the echo is a value the pointer moved past already. + if (!s.volDrag) s.volume = data; + return null; + case "audio-fx": + // The daemon's authoritative effects, echoed after every apply and on subscribe. + s.audioFx = data; + return null; + case "provider-changed": + // The active provider, echoed when it switches; shape is { provider }. + s.provider = data.provider; + return null; + case "spotify-auth": { + // The Spotify OAuth flow's progress, surfaced by the daemon; no provider-changed rides with + // it. The url step is opened in the browser by the caller (a JS library cannot reach + // Quickshell), so it returns an openUrl the Playback singleton hands to execDetached. + var st = (data && data.state) ? data.state : ""; + if (st === "url") { + s.spotify = Object.assign({}, s.spotify, { flow: "browser", error: "" }); + return { toast: "Opening Spotify sign-in in your browser", kind: "info", openUrl: data.url }; + } + if (st === "signed_in") { + s.spotify = { signedIn: true, stored: true, name: (data && data.name) ? data.name : null, + premium: (s.spotify && s.spotify.premium !== undefined) ? s.spotify.premium : null, + flow: "", error: "" }; + return { toast: "Signed in to Spotify" + ((data && data.name) ? (" as " + data.name) : ""), kind: "success" }; + } + if (st === "restored") { + // The startup restore finished after this client's opening snapshot was taken -- + // the fix for the sign-in gate that reappeared on every launch. Restore is gated + // on Premium, so premium is known true; the display name arrives with a snapshot. + s.spotify = { signedIn: true, stored: true, + name: (s.spotify && s.spotify.name) ? s.spotify.name : null, + premium: true, flow: "", error: "" }; + return null; + } + if (st === "restore_failed") { + // Credentials are on disk but the cached session is dead: keep the reason on the + // gate instead of silently demanding a fresh sign-in. + var why = (data && data.message) ? data.message : "Spotify sign-in no longer works"; + s.spotify = Object.assign({}, s.spotify, { signedIn: false, stored: true, error: why }); + return null; + } + if (st === "signed_out") { + s.spotify = { signedIn: false, stored: false, name: null, premium: null }; + return null; + } + if (st === "error") { + var msg = (data && data.message) ? data.message : "Spotify sign-in failed"; + // Keep the reason on the gate: a toast alone reads as "nothing happened". + s.spotify = Object.assign({}, s.spotify, { flow: "", error: msg }); + return { toast: msg, kind: "error" }; + } + return null; + } + case "soundcloud-auth": { + // The SoundCloud sign-in flow's progress. Sign-in happens in the user's own browser + // (captchas and IdP popups work there); the daemon opens it and watches the browser's + // cookie store, so the states are: waiting (browser opened, polling), signed_in, + // signed_out, expired (nobody finished in the browser), restore_failed (a persisted + // token died) and error. + var sc = (data && data.state) ? data.state : ""; + if (sc === "signed_in") { + s.soundcloud = { signedIn: true, name: (data && data.name) ? data.name : null, error: "" }; + return { toast: "Signed in to SoundCloud" + ((data && data.name) ? (" as " + data.name) : ""), kind: "success" }; + } + if (sc === "signed_out") { + s.soundcloud = { signedIn: false, name: null, error: "" }; + return null; + } + if (sc === "waiting") { + s.soundcloud = { signedIn: false, name: null, error: "Finish signing in at soundcloud.com in your browser \u2014 Ryotunes picks the session up automatically." }; + return { toast: "Opened soundcloud.com in your browser \u2014 sign in there and Ryotunes will connect", kind: "info" }; + } + if (sc === "expired") { + s.soundcloud = { signedIn: false, name: null, error: "" }; + return { toast: "No SoundCloud sign-in was completed in the browser", kind: "info" }; + } + if (sc === "restore_failed" || sc === "error") { + var why = (data && data.message) ? data.message : "SoundCloud sign-in failed"; + s.soundcloud = { signedIn: false, name: null, error: why }; + return { toast: why, kind: "error" }; + } + return null; + } + case "playback-error": + s.lastError = (data && data.message !== undefined) ? data.message : String(data); + s.pendingVideoId = null; + return { toast: s.lastError, kind: "error" }; + case "playback-notice": // auto-skipped an unplayable track + return { toast: (data && data.message !== undefined) ? data.message : String(data), kind: "info" }; + case "cover-error": // playlist artwork YouTube would not take + return { toast: (data && data.message !== undefined) ? data.message : String(data), kind: "error" }; + case "auth-changed": + s.auth = { signedIn: !!data.signedIn, name: data.name, avatar: data.avatar }; + return null; + case "lt-state": + s.lt = data; + return null; + case "lt-notice": + return { toast: String(data), kind: "info" }; + default: + return null; + } +} diff --git a/client/lib/playlist-links.js b/client/lib/playlist-links.js new file mode 100644 index 0000000..aed6ab3 --- /dev/null +++ b/client/lib/playlist-links.js @@ -0,0 +1,146 @@ +.pragma library + +// A pasted YouTube / YouTube Music link turned into a playlist the shortcut picker can add (#63): +// some playlists are only reachable by URL — they don't show up in search and aren't in the +// library — so without this there's no way in. Pure and network-free: the picker resolves an id +// here, then fetches real metadata through `get_playlist` (never the pasted URL itself). +// +// The one target this resolves is a playlist. The picker's link field adds *playlist* shortcuts, so +// a watch/share link is read for its `list=` playlist rather than the single track it was shared +// from (the deliberate opposite of ShareDialog/ytlink, which open the shared track). Manual URL +// parsing on purpose: QML's JS engine has no `URL` constructor, and this must also run under +// qmltestrunner with no Quickshell. + +// The exact hosts we open. A lookalike ("youtube.com.evil.test") is not on the list and is refused. +var HOSTS = { + "youtube.com": true, + "www.youtube.com": true, + "m.youtube.com": true, + "music.youtube.com": true, + "youtu.be": true +}; + +// A YouTube playlist id: PL…, OLAK5uy_…, RDCLAK5uy_…, a bare VL browse id, etc. +var ID_RE = /^[A-Za-z0-9_-]+$/; +// A syntactically plausible hostname (letters/digits/dots/hyphens). Rejects "not a url at all". +var HOST_RE = /^[a-z0-9.-]+$/; + +function fail(reason, message) { + return { ok: false, reason: reason, message: message }; +} + +// Percent/`+` decode one query token, tolerating a malformed sequence rather than throwing. +function decodeToken(s) { + try { + return decodeURIComponent(s.replace(/\+/g, " ")); + } catch (e) { + return s; + } +} + +// Parse a query string into { key: [values…] } with every value decoded. Repeated keys accumulate, +// so a link carrying two different `list=` can be spotted as ambiguous. +function parseQuery(q) { + var out = Object.create(null); + if (!q) + return out; + var parts = q.split("&"); + for (var i = 0; i < parts.length; i++) { + if (!parts[i]) + continue; + var eq = parts[i].indexOf("="); + var k = eq < 0 ? parts[i] : parts[i].slice(0, eq); + var v = eq < 0 ? "" : parts[i].slice(eq + 1); + k = decodeToken(k); + if (!Object.prototype.hasOwnProperty.call(out, k)) + out[k] = []; + out[k].push(decodeToken(v)); + } + return out; +} + +// Resolve a pasted link to a playlist: +// { ok: true, id, rawId } — id is the `VL…` browse id `get_playlist` wants +// { ok: false, reason, message } — a clear, recoverable reason the field can show +// `reason: "empty"` (blank box) is neutral: the field shows nothing rather than an error. +function parsePlaylistLink(input) { + var text = (input === undefined || input === null) ? "" : String(input).trim(); + if (!text) + return fail("empty", ""); + if (text.length > 4096) + return fail("invalid", "That link is too long."); + + // Scheme: require http/https. Assume https for a bare host paste ("music.youtube.com/…"), but + // an explicit foreign scheme (ftp:, file:, javascript:, …) is refused outright. + var schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(text); + var rest; + if (schemeMatch) { + var scheme = schemeMatch[1].toLowerCase(); + if (scheme !== "http" && scheme !== "https") + return fail("scheme", "Only http and https links are supported."); + rest = text.slice(schemeMatch[0].length); + } else { + rest = text; + } + + // Split authority from the path/query (up to the first "/", "?" or "#"). + var cut = rest.search(/[\/?#]/); + var authority = cut < 0 ? rest : rest.slice(0, cut); + var tail = cut < 0 ? "" : rest.slice(cut); + + // Credentials are not part of a share link. No pasted authority is ever fetched. + if (authority.indexOf("@") >= 0) + return fail("invalid", "Paste a public share link without login details."); + var host = authority.replace(/:\d+$/, "").toLowerCase(); + if (!host || !HOST_RE.test(host)) + return fail("invalid", "That does not look like a link."); + if (!HOSTS.hasOwnProperty(host)) + return fail("host", "Only YouTube and YouTube Music links are supported."); + + // Fragment is irrelevant; separate path and query. + var hash = tail.indexOf("#"); + if (hash >= 0) + tail = tail.slice(0, hash); + var path = tail; + var query = ""; + var qi = tail.indexOf("?"); + if (qi >= 0) { + path = tail.slice(0, qi); + query = tail.slice(qi + 1); + } + var params = parseQuery(query); + var segs = path.replace(/^\/+/, "").split("/").filter(function (s) { return s.length > 0; }); + + // The playlist id. `list=` covers /playlist, a watch/share link, and youtu.be/?list=…; a + // YTM /browse/VL… page is the playlist's own browse id. + var raw = null; + var lists = params["list"] || []; + if (lists.length > 0) { + var seen = Object.create(null); + for (var i = 0; i < lists.length; i++) + seen[lists[i]] = true; + if (Object.keys(seen).length > 1) + return fail("ambiguous", "This link points at more than one playlist."); + raw = lists[0]; + } else if (segs[0] === "browse" && segs[1]) { + var bid = segs[1]; + if (/^VL/.test(bid)) + raw = bid; + else if (bid.indexOf("MPRE") === 0) + return fail("no-playlist", "That link is an album, not a playlist."); + else if (bid.indexOf("UC") === 0) + return fail("no-playlist", "That link is an artist, not a playlist."); + else + return fail("no-playlist", "That link is not a playlist."); + } else { + return fail("no-playlist", "That link has no playlist — paste a playlist or a \u201CShare playlist\u201D link."); + } + + raw = String(raw).trim(); + // A playlist browse id carries a VL prefix the URL's `list=` does not; normalise so it is never + // doubled and always canonical. + var rawId = raw.replace(/^VL/, ""); + if (!rawId || rawId.length > 256 || !ID_RE.test(rawId)) + return fail("bad-id", "That does not look like a valid playlist id."); + return { ok: true, id: "VL" + rawId, rawId: rawId }; +} diff --git a/client/lib/recommendations.js b/client/lib/recommendations.js new file mode 100644 index 0000000..00db6b4 --- /dev/null +++ b/client/lib/recommendations.js @@ -0,0 +1,363 @@ +.pragma library +.import "ids.js" as Ids + +// A small, explainable, on-device discovery ranker for Home. It re-ranks the items the active +// provider already put in the feed (get_home sections) using the local listening signals the +// personal store records — nothing leaves the machine, nothing is trained, nothing is invented. +// +// It is a lightweight hybrid, written from scratch: no model, no service, no third-party code. The +// shape borrows only ideas that are public: +// - a linear blend of a few normalised signals (the "hybrid" of retrieval systems such as +// implicit — benfred/implicit, MIT — and LightFM — lyst/lightfm, Apache-2.0 — without adopting +// any of their code, weights, or matrix factorisation: a single user's play history does not +// justify training an ALS/WARP model, and pretending it did would be dishonest); +// - Maximal Marginal Relevance for the diversity pass, the standard +// mmr = lambda * relevance - (1 - lambda) * maxSimilarityToAlreadyChosen +// (see elastic.co/search-labs/blog/maximum-marginal-relevance-diversify-results). The formula +// is math, not licensed code; the implementation below is original. +// +// Signals, all clamped to [0, 1] before weighting: +// provider rank — the feed's own ordering, trusted as a prior (the provider ranked it for a +// reason); earlier => higher. This carries a cold start honestly: with no local +// history the shelf is simply the provider's discovery order, diversified. +// artist affinity— how often the user plays this item's artist (personal.artists counts). +// artist recency — how lately they have been on that artist (decayed, artists[].lastPlayedAt). +// pick affinity — shares an artist with one of the user's shortcut tiles. +// replay fatigue — a penalty when this exact id was played recently (decayed): a discovery shelf +// should not hand back the track you just finished. +// metadata — a small penalty for artwork-less rows so thin/placeholder items do not lead. +// +// Provider boundaries are kept: personal history is scoped to the id family of the active provider, +// so a Spotify/SoundCloud feed does not inherit YouTube Music affinities (it stays an honest cold +// start), and feed rows that positively belong to a *different* provider are dropped outright. +// +// build(sections, personalBlob, provider, nowMs) -> { items, explanation } +// items : up to 12 original BrowseItem-compatible rows (shallow clones + an optional +// `reason` string), deduped by id, never a mutation of the inputs. +// explanation : a short, honest sentence about how this set was chosen (cold vs. personalised), +// always present — even when items is empty. + +var OUT_MAX = 12; // the shelf holds twelve cards +var POOL_MAX = 300; // bound the candidate pool: the feed can be long, ranking is not free + +var DAY_MS = 86400000; +var RECENCY_HALF_LIFE_MS = 14 * DAY_MS; // an artist you were on two weeks ago is half as "current" +var FATIGUE_HALF_LIFE_MS = 3 * DAY_MS; // a track replayed three days ago fatigues half as much + +var RANK_DECAY = 0.06; // provider-rank prior falloff: rank 0 -> 1.0, ~10 -> 0.63, ~25 -> 0.4 +var W_RANK = 0.45; // weight on the provider's own ordering (the base everyone starts from) +var W_ARTIST = 0.40; // weight on play-count affinity +var W_RECENCY = 0.20; // weight on how lately that artist was played +var W_PICK = 0.15; // weight on sharing an artist with a shortcut +var W_FATIGUE = 0.70; // strength of the "just played this" penalty +var W_META = 0.30; // strength of the artwork-less penalty + +var MMR_LAMBDA = 0.72; // relevance vs. diversity: high enough that a genuinely better item wins +var SIM_ARTIST = 1.0; // two rows by the same artist are maximally redundant +var SIM_KIND = 0.28; // two rows of the same kind are mildly redundant + +function providerOf(id) { + return Ids.providerOf(id); +} + +function isForeign(id, provider) { + return providerOf(id) !== provider; +} + +// The lead artist of a joined credit string — same rule as personal.js firstArtist, kept local so +// this module stays a standalone pure library. Tolerant of non-strings. +function firstArtist(artists) { + if (typeof artists !== "string" || !artists) + return ""; + return artists.split(/[,&\u2022]|\sfeat\.?\s|\sft\.?\s/i)[0].trim(); +} + +// 2^(-elapsed / halfLife). Future/negative elapsed (clock skew) reads as "just now" -> 1. +function decay(elapsedMs, halfLifeMs) { + if (!(elapsedMs > 0)) + return 1; + return Math.pow(2, -elapsedMs / halfLifeMs); +} + +// The store keys an artist by its channel id when it has one, otherwise by the whole credit string +// (personal.js noteArtist). Match that exactly so affinity lookups actually hit. +function affinityKeyOf(item) { + if (item.artistId && typeof item.artistId === "string") + return item.artistId; + if (Array.isArray(item.artistRuns)) { + for (var i = 0; i < item.artistRuns.length; i++) + if (item.artistRuns[i] && item.artistRuns[i].id) + return item.artistRuns[i].id; + } + if (item.kind === "artist") + return item.id; + return firstArtist(item.subtitle); +} + +// A looser key for the diversity pass: the lead artist, case-folded, so two songs sharing a lead +// (even across different collab credits) read as the same artist. Empty when there is no artist. +function simArtistOf(item) { + return String(affinityKeyOf(item) || "").toLowerCase(); +} + +// A row worth ranking: a real id and a title. Rows missing either are the "fake"/placeholder rows a +// discovery shelf must not promote — dropped, not penalised. +function usable(item) { + return !!item + && typeof item.id === "string" && item.id.length > 0 + && typeof item.title === "string" && item.title.trim().length > 0 + && ["song", "album", "playlist", "artist"].indexOf(item.kind) >= 0; +} + +// Fold the personal blob into the fast lookups scoring needs, scoped to the active provider so one +// catalogue's history never leaks into another's feed. Returns null-ish maps that are always safe +// to read. +function personalIndex(blob, provider, nowMs) { + var idx = { + recentAt: {}, // id -> last-played epoch ms (for replay fatigue) + artists: {}, // artist key -> { count, lastPlayedAt } + artistNames: Object.create(null), + maxCount: 0, // for normalising affinity + pickIds: {}, // shortcut ids -> true (skip: already surfaced) + pickArtists: {}, // shortcut artist keys -> true (affinity) + personalized: false + }; + if (!blob || typeof blob !== "object") + return idx; + + var recent = (blob.recent && typeof blob.recent === "object") ? blob.recent : {}; + for (var id in recent) { + if (!recent.hasOwnProperty(id)) + continue; + if (providerOf(id) !== provider) + continue; // cross-provider: not this catalogue's history + var r = recent[id]; + var at = r && typeof r.at === "number" ? r.at : 0; + idx.recentAt[id] = at; + idx.personalized = true; + } + + var artists = (blob.artists && typeof blob.artists === "object") ? blob.artists : {}; + for (var key in artists) { + if (!artists.hasOwnProperty(key)) + continue; + if (providerOf(key) !== provider) + continue; + var a = artists[key]; + if (!a || typeof a !== "object") + continue; + var count = typeof a.count === "number" && a.count > 0 ? a.count : 0; + if (count <= 0) + continue; + idx.artists[key] = { + count: count, + lastPlayedAt: typeof a.lastPlayedAt === "number" ? a.lastPlayedAt : 0, + name: typeof a.name === "string" ? a.name : "" + }; + var nameKey = firstArtist(idx.artists[key].name).toLowerCase(); + if (nameKey && (!idx.artistNames[nameKey] || idx.artistNames[nameKey].count < count)) + idx.artistNames[nameKey] = idx.artists[key]; + if (count > idx.maxCount) + idx.maxCount = count; + idx.personalized = true; + } + + var picks = Array.isArray(blob.picks) ? blob.picks : []; + for (var i = 0; i < picks.length; i++) { + var p = picks[i]; + if (!p || typeof p.id !== "string") + continue; + idx.pickIds[p.id] = true; + if (providerOf(p.id) !== provider) + continue; + var pk = affinityKeyOf(p); + if (pk) + idx.pickArtists[pk] = true; + idx.personalized = true; + } + return idx; +} + +// Build the deduped, provider-scoped, bounded candidate pool out of the feed's sections, preserving +// the provider's ordering as the rank. Never mutates a section or an item. +function poolFrom(sections, provider, idx) { + var pool = []; + var seen = {}; + var list = Array.isArray(sections) ? sections : []; + for (var s = 0; s < list.length && pool.length < POOL_MAX; s++) { + var sec = list[s]; + var items = (sec && Array.isArray(sec.items)) ? sec.items : []; + for (var i = 0; i < items.length && pool.length < POOL_MAX; i++) { + var it = items[i]; + if (!usable(it)) + continue; + if (seen[it.id]) + continue; // full id dedup — first (best-ranked) wins + seen[it.id] = true; + if (isForeign(it.id, provider)) + continue; // cross-provider exclusion + if (idx.pickIds[it.id]) + continue; // already a shortcut tile — redundant to re-offer + pool.push({ item: it, rank: pool.length }); + } + } + return pool; +} + +// Score one candidate: a relevance number plus the per-signal parts (kept for the reason string). +function score(entry, idx, nowMs) { + var item = entry.item; + var prior = 1 / (1 + RANK_DECAY * entry.rank); + + var akey = affinityKeyOf(item); + var art = idx.artists[akey] || idx.artistNames[firstArtist(item.subtitle).toLowerCase()]; + var affinity = (art && idx.maxCount > 0) ? (art.count / idx.maxCount) : 0; + var recency = (art && art.lastPlayedAt > 0) ? decay(nowMs - art.lastPlayedAt, RECENCY_HALF_LIFE_MS) : 0; + var pick = (akey && idx.pickArtists[akey]) ? 1 : 0; + + var playedAt = idx.recentAt.hasOwnProperty(item.id) ? idx.recentAt[item.id] : null; + var fatigue = (playedAt !== null) ? decay(nowMs - playedAt, FATIGUE_HALF_LIFE_MS) : 0; + + var thin = (typeof item.thumbnail === "string" && item.thumbnail.length > 0) ? 0 : 1; + + var relevance = W_RANK * prior + + W_ARTIST * affinity + + W_RECENCY * recency + + W_PICK * pick + - W_FATIGUE * fatigue + - W_META * thin; + + entry.rel = relevance; + entry.parts = { affinity: W_ARTIST * affinity, recency: W_RECENCY * recency, + pick: W_PICK * pick, fatigue: fatigue }; + entry.sim = { artist: art && art.name ? firstArtist(art.name).toLowerCase() : simArtistOf(item), + kind: typeof item.kind === "string" ? item.kind : "" }; + entry.artistName = (art && art.name) ? art.name : firstArtist(item.subtitle); + return entry; +} + +// Redundancy of a candidate against an already-chosen one: same artist dominates, same kind is a +// gentle nudge. Max over the chosen set is what MMR subtracts. +function similarity(a, b) { + if (a.sim.artist && b.sim.artist && a.sim.artist === b.sim.artist) + return SIM_ARTIST; + if (a.sim.kind && b.sim.kind && a.sim.kind === b.sim.kind) + return SIM_KIND; + return 0; +} + +// Order-stable comparison for candidates: relevance desc, then the provider's rank, then id. Pure +// so ties never depend on array order or floating jitter — the shelf is the same every build. +function beforeByRelevance(x, y) { + if (y.rel !== x.rel) + return y.rel - x.rel; + if (x.rank !== y.rank) + return x.rank - y.rank; + return x.item.id < y.item.id ? -1 : (x.item.id > y.item.id ? 1 : 0); +} + +// The MMR diversity pass. Greedily pick the candidate maximising +// lambda * relevance - (1 - lambda) * maxSimilarity(candidate, chosen) +// Ties resolve by the stable relevance order above, so the result is deterministic. A much stronger +// candidate still wins over a diverse-but-weak one (relevance survives); a near-tie by the same +// artist yields to a fresh one (diversity survives). +function rerank(scored, limit) { + var chosen = []; + for (var i = 0; i < scored.length; i++) { + scored[i].maxSimilarity = 0; + scored[i].selected = false; + } + while (chosen.length < limit && chosen.length < scored.length) { + var previous = chosen.length ? chosen[chosen.length - 1] : null; + var best = null; + var bestScore = 0; + for (var j = 0; j < scored.length; j++) { + var candidate = scored[j]; + if (candidate.selected) + continue; + // Update against only the newly selected item: previous maxima are still + // valid. This is O(pool × output), not a repeated scan of the chosen set. + if (previous) + candidate.maxSimilarity = Math.max(candidate.maxSimilarity, similarity(candidate, previous)); + var mmr = MMR_LAMBDA * candidate.rel - (1 - MMR_LAMBDA) * candidate.maxSimilarity; + if (best === null || mmr > bestScore + || (mmr === bestScore && beforeByRelevance(candidate, best) < 0)) { + best = candidate; + bestScore = mmr; + } + } + if (!best) + break; + best.selected = true; + chosen.push(best); + } + return chosen; +} + +// A short, honest reason for one row, from whichever positive personal signal contributed most. +// Falls back to the provider prior (a genuine cold-start row) rather than claiming a learned pick. +function reasonFor(entry, provider) { + var p = entry.parts; + var name = entry.artistName; + if (name && p.affinity >= p.recency && p.affinity >= p.pick && p.affinity > 0) + return "More from " + name + ", one of your regulars"; + if (name && p.recency >= p.pick && p.recency > 0) + return "You've had " + name + " on lately"; + if (name && p.pick > 0) + return "Close to your shortcut " + name; + return "From " + providerLabel(provider) + "'s recommendations"; +} + +function providerLabel(provider) { + if (provider === "youtube") + return "YouTube Music"; + if (provider === "spotify") + return "Spotify"; + if (provider === "soundcloud") + return "SoundCloud"; + return "your feed"; +} + +// A shallow clone with the reason attached — the output row is BrowseItem-compatible and never the +// caller's own object, so scoring can annotate without touching the feed. +function present(entry, provider) { + var out = {}; + var item = entry.item; + for (var k in item) + if (item.hasOwnProperty(k)) + out[k] = item[k]; + out.reason = reasonFor(entry, provider); + return out; +} + +// The whole feed-resolved explanation: personalised when local history drove the order, an honest +// cold-start line otherwise, and a useful line even when there is nothing to show. +function explain(personalized, provider, count) { + if (count === 0) { + return "Nothing new to surface here yet — this fills in as your " + + providerLabel(provider) + " feed grows."; + } + if (personalized) { + return "Based on your recent listening and shortcuts, with room for different artists. Ranked on your device."; + } + return "A varied selection from " + providerLabel(provider) + ". Your listening will help shape future picks."; +} + +// Entry point. Pure: reads sections/personalBlob, returns a new { items, explanation }. +function build(sections, personalBlob, provider, nowMs) { + var prov = typeof provider === "string" && provider ? provider : "youtube"; + var now = typeof nowMs === "number" && isFinite(nowMs) ? nowMs : Date.now(); + + var idx = personalIndex(personalBlob, prov, now); + var pool = poolFrom(sections, prov, idx); + + if (!pool.length) + return { items: [], explanation: explain(idx.personalized, prov, 0) }; + + for (var i = 0; i < pool.length; i++) + score(pool[i], idx, now); + + var chosen = rerank(pool, OUT_MAX); + var items = chosen.map(function (e) { return present(e, prov); }); + return { items: items, explanation: explain(idx.personalized, prov, items.length) }; +} diff --git a/client/lib/search.js b/client/lib/search.js new file mode 100644 index 0000000..5ffdad3 --- /dev/null +++ b/client/lib/search.js @@ -0,0 +1,35 @@ +.pragma library + +// The small ranked result set the typeahead surfaces (SearchSuggest, the Ctrl+K palette), ported +// from browse.ts previewMix: the top hit first, then a round-robin across songs / artists / albums +// / playlists rather than six songs, deduped by kind:id. Kept pure so both surfaces show the same +// rows for the same query. +function previewMix(res, limit) { + limit = limit || 16; + var out = []; + var seen = {}; + function push(item) { + if (!item) + return; + var key = item.kind + ":" + item.id; + if (seen[key]) + return; + seen[key] = true; + out.push(item); + } + var top = res.top || []; + for (var i = 0; i < top.length; i++) + push(top[i]); + var groups = [res.songs || [], res.artists || [], res.albums || [], res.playlists || []]; + for (var row = 0; out.length < limit; row++) { + var added = false; + for (var g = 0; g < groups.length; g++) { + if (groups[g][row]) { push(groups[g][row]); added = true; } + if (out.length >= limit) + break; + } + if (!added) + break; + } + return out.slice(0, limit); +} diff --git a/client/lib/style.js b/client/lib/style.js new file mode 100644 index 0000000..e385a40 --- /dev/null +++ b/client/lib/style.js @@ -0,0 +1,40 @@ +.pragma library + +// Pure helpers shared by Style.qml and its QtTest case. They live here rather than as methods on +// the Style singleton because a QtTest process cannot load Quickshell (Style imports Tokens, which +// imports Quickshell), so the test imports this module directly. Style re-exposes them unchanged. + +// Rewrite a Google image URL to about the pixel size a slot actually renders, so the client does +// not decode a 544 px image for a 40 px row. Line-for-line port of ui/src/lib/thumb.ts with +// Tauri's convertFileSrc replaced by a "file://" prefix (Quickshell Image opens file URLs directly). +function thumb(url, px) { + if (!url) return undefined; + // Local library artwork is a path on this machine, not a URL. Kept as the real path everywhere + // it is stored (queue, MPRIS); only rewritten to a file URL at the point of display. + if (url.charAt(0) === "/" || /^[A-Za-z]:[\\/]/.test(url)) return "file://" + url; + if (/=w\d+-h\d+/.test(url)) return url.replace(/=w\d+-h\d+/, "=w" + px + "-h" + px); + if (/=s\d+/.test(url)) return url.replace(/=s\d+/, "=s" + px); + return url; +} + +// Seconds -> "m:ss" (minutes not zero-padded, seconds always two digits). Floors sub-second input, +// clamps NaN/negative to zero, so a live position sample renders without flicker. +function fmtTime(secs) { + if (!(secs > 0) || !isFinite(secs)) secs = 0; + var s = Math.floor(secs); + var m = Math.floor(s / 60); + var r = s % 60; + return m + ":" + (r < 10 ? "0" + r : r); +} + +// A play / like / follower count -> a compact label ("27.3K", "1.2M"). A string passes through (the +// daemon may already have formatted it); undefined/null/empty and non-finite values yield "". +function fmtCount(v) { + if (v === undefined || v === null || v === "") return ""; + if (typeof v === "string") return v; + var n = Number(v); + if (!isFinite(n)) return ""; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K"; + return String(Math.round(n)); +} diff --git a/client/mini/MiniPlayer.qml b/client/mini/MiniPlayer.qml new file mode 100644 index 0000000..796a6aa --- /dev/null +++ b/client/mini/MiniPlayer.qml @@ -0,0 +1,518 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../chrome" +import "../lib/ids.js" as Ids + +// The compact player, the Tauri build's 724 x 356 widget (MiniPlayer.svelte, ryo-mini-v2): a full- +// height artwork column on the left with the track name over its foot, and on the right a head +// (brand, Now / Lyrics / Queue tabs, like, maximize), the body for the chosen tab and a footer with +// the transport and the volume. It lives in shell.qml's layer-shell PanelWindow; like every surface +// it holds no truth, it renders Playback and calls Playback. The lyrics word-timer is gated on the +// widget's real visibility (`active`) AND the lyrics tab, so a hidden mini never wakes it. +Item { + id: root + + // True only while the widget is mapped; gates the lyrics timer off when hidden. + property bool active: true + // Raised when the user asks to return to the full window. + signal maximize() + // The item the header and artwork drag (the widget's box inside its layer surface), with the + // box's travel bounds; dragEnded lets the window persist where it landed. + property Item dragTarget: null + property real dragMaxX: 0 + property real dragMaxY: 0 + signal dragEnded() + + property string view: "now" // "now" | "lyrics" | "queue" + property int preMute: 100 + + readonly property var now: Playback.now + readonly property bool hasYouTubeTrack: !!Playback.now + && !Ids.isLocalId(Playback.now.videoId) && !Ids.isRadioId(Playback.now.videoId) + readonly property var nextUp: { + var q = Playback.queue; + return (q && q.items) ? (q.items[q.currentIndex + 1] || null) : null; + } + readonly property int queueLeft: Math.max(0, Playback.queue.items.length - Playback.queue.currentIndex - 1) + readonly property real radius: 16 + readonly property int artW: 232 + + function toggleLike() { + var n = Playback.now; + if (!n || !root.hasYouTubeTrack) + return; + var next = Playback.rating === "like" ? "indifferent" : "like"; + Playback.rating = next; + Daemon.call("rate", { videoId: n.videoId, rating: next }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not rate", "error")); + } + function toggleMute() { + var muted = Playback.volume === 0; + if (!muted) + root.preMute = Playback.volume; + var v = muted ? (root.preMute || 100) : 0; + Playback.volume = v; + Playback.setVolume(v); + Daemon.call("set_setting", { key: "volume", value: String(v) }).catch(() => {}); + } + function mix(a, b, t) { return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, 1); } + + // The whole widget is clipped to a 16 px rounded rectangle by one shader layer, so the + // artwork column and the panel share the same corners on a transparent layer surface. + Item { + id: panel + anchors.fill: parent + layer.enabled: true + layer.effect: ShaderEffect { + property vector2d size: Qt.vector2d(panel.width * panel.Screen.devicePixelRatio, panel.height * panel.Screen.devicePixelRatio) + property real radius: root.radius * panel.Screen.devicePixelRatio + fragmentShader: Qt.resolvedUrl("../shaders/rounded.frag.qsb") + } + + // Paper tinted 3% toward the accent, the accent's glow ellipse off the top-left. + Rectangle { anchors.fill: parent; color: root.mix(Tokens.paper, Style.accent, 0.03) } + ProviderGlow { anchors.fill: parent; focusX: 0.32; focusY: -0.05; strength: 0.22; radius: 0.45 } + + // ── artwork column ───────────────────────────────────────────────────────────── + Item { + id: art + width: root.artW + height: parent.height + + Rectangle { anchors.fill: parent; color: Tokens.paperLift } + Image { + id: cover + anchors.fill: parent + source: (root.now && root.now.thumbnail) ? Style.thumb(root.now.thumbnail, 480) : "" + sourceSize: Qt.size(480, 480) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Tokens.durDefaultEffects } } + } + Icon { + anchors.centerIn: parent + visible: cover.status !== Image.Ready + name: "music" + size: 40 + color: Tokens.inkFaint + } + // Foot gradient so the copy reads over any cover. + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.30; color: Qt.rgba(0, 0, 0, 0.01) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.72) } + } + } + // "// LIVE" pill + Rectangle { + x: 16; y: 15 + width: liveText.implicitWidth + 16 + height: 20 + radius: 10 + color: Qt.rgba(7 / 255, 7 / 255, 8 / 255, 0.48) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.18) + Text { + id: liveText + anchors.centerIn: parent + text: (!!root.now && !Playback.paused) ? "// LIVE" : "// PAUSED" + color: Qt.rgba(1, 1, 1, 0.88) + font.family: Style.fontMono + font.pixelSize: 8 + font.weight: Font.DemiBold + font.letterSpacing: 1.2 + } + } + ColumnLayout { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom; margins: 17 } + anchors.bottomMargin: 18 + spacing: 5 + Text { + Layout.fillWidth: true + text: (root.now && root.now.title) ? root.now.title : "Nothing playing" + color: "white" + font.family: Style.fontUi + font.pixelSize: 14 + font.weight: Font.DemiBold + font.letterSpacing: -0.14 + elide: Text.ElideRight + } + RowLayout { + Layout.fillWidth: true + spacing: 4 + // The provider tell for the playing track: spotify glyph for spotify: ids, the + // YouTube Music glyph otherwise. + Icon { + visible: !!root.now + name: (root.now && root.now.videoId && String(root.now.videoId).startsWith("spotify:")) ? "spotify" : "youtube-music" + size: 14 + color: Qt.rgba(1, 1, 1, 0.70) + } + Text { + Layout.fillWidth: true + text: (root.now && root.now.artists) ? root.now.artists : "Ryotunes is ready" + color: Qt.rgba(1, 1, 1, 0.70) + font.family: Style.fontUi + font.pixelSize: 10 + elide: Text.ElideRight + } + } + } + // Tap the cover to play / pause (the full window's preview does the same); drag it to + // move the widget. + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { enabled: !!root.now; onTapped: Playback.togglePause() } + DragHandler { + target: root.dragTarget + xAxis.minimum: 0 + xAxis.maximum: root.dragMaxX + yAxis.minimum: 0 + yAxis.maximum: root.dragMaxY + onActiveChanged: if (!active) root.dragEnded() + } + Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: Qt.rgba(Tokens.ink.r, Tokens.ink.g, Tokens.ink.b, 0.12) } + } + + // ── main column ──────────────────────────────────────────────────────────────── + ColumnLayout { + id: main + anchors { left: art.right; right: parent.right; top: parent.top; bottom: parent.bottom } + anchors.leftMargin: 19 + anchors.rightMargin: 18 + anchors.topMargin: 11 + anchors.bottomMargin: 13 + spacing: 0 + + // head: brand | tabs | like, maximize -- and the drag grip for the whole widget + Item { + id: head + Layout.fillWidth: true + Layout.preferredHeight: 54 + + DragHandler { + target: root.dragTarget + xAxis.minimum: 0 + xAxis.maximum: root.dragMaxX + yAxis.minimum: 0 + yAxis.maximum: root.dragMaxY + onActiveChanged: if (!active) root.dragEnded() + } + + RowLayout { + anchors.fill: parent + anchors.bottomMargin: 9 + spacing: 14 + + RowLayout { + spacing: 8 + Rectangle { Layout.preferredWidth: 17; Layout.preferredHeight: 1; color: Tokens.inkDim; opacity: 0.58 } + Text { + text: "力 RYOTUNES" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 1.35 + } + } + + Item { Layout.fillWidth: true } + + // tabs + Rectangle { + implicitWidth: tabRow.implicitWidth + 6 + implicitHeight: 40 + radius: 12 + color: Qt.rgba(Tokens.paperLift.r, Tokens.paperLift.g, Tokens.paperLift.b, 0.78) + border.width: 1 + border.color: Tokens.lineSoft + RowLayout { + id: tabRow + anchors.centerIn: parent + spacing: 7 + Repeater { + model: [ + { key: "now", icon: "music", tip: "Now playing" }, + { key: "lyrics", icon: "mic", tip: "Lyrics" }, + { key: "queue", icon: "queue", tip: "Queue" } + ] + delegate: Rectangle { + id: tab + required property var modelData + readonly property bool on: root.view === tab.modelData.key + Layout.preferredWidth: 38 + Layout.preferredHeight: 34 + radius: 9 + color: tab.on ? Tokens.paperLift : (tabHover.hovered ? Tokens.tint5 : "transparent") + border.width: tab.on ? 1 : 0 + border.color: Tokens.line + Icon { + anchors.centerIn: parent + name: tab.modelData.icon + size: 16 + color: tab.on ? Tokens.ink : Tokens.inkFaint + } + Rectangle { + visible: tab.on + anchors { left: parent.left; right: parent.right; bottom: parent.bottom; leftMargin: 14; rightMargin: 14; bottomMargin: 3 } + height: 2 + radius: 2 + color: Tokens.ink + opacity: 0.78 + } + HoverHandler { id: tabHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.view = tab.modelData.key } + } + } + } + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: 6 + IconButton { + visible: root.hasYouTubeTrack + icon: "heart"; iconSize: 16; diameter: 32 + active: Playback.rating === "like" + iconColor: Playback.rating === "like" ? Style.accent : Tokens.inkMuted + tip: "Like" + onClicked: root.toggleLike() + } + IconButton { icon: "rail-expand"; iconSize: 16; diameter: 32; tip: "Open full Ryotunes"; onClicked: root.maximize() } + } + } + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 1 + color: Tokens.lineSoft + } + } + + // body + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + // NOW + ColumnLayout { + anchors.fill: parent + visible: root.view === "now" + spacing: 0 + + Item { Layout.fillHeight: true } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + spacing: 8 + Text { + Layout.fillWidth: true + text: (root.now && root.now.title) ? root.now.title : "Nothing playing" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: 29 + font.weight: Font.Normal + font.letterSpacing: -0.8 + elide: Text.ElideRight + } + RowLayout { + Layout.fillWidth: true + spacing: 5 + // The provider tell for the playing track: spotify glyph for spotify: + // ids, the YouTube Music glyph otherwise. + Icon { + visible: !!root.now + name: (root.now && root.now.videoId && String(root.now.videoId).startsWith("spotify:")) ? "spotify" : "youtube-music" + size: 14 + color: Tokens.inkMuted + } + Text { + Layout.fillWidth: true + text: (root.now && root.now.artists) ? root.now.artists : "Ryotunes is ready" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + } + } + } + Item { Layout.fillHeight: true } + + // seek + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 36 + spacing: 10 + Text { + Layout.preferredWidth: 36 + text: Style.fmtTime(Playback.shownPosition) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.DemiBold + } + Slider { + id: miniSeek + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + from: 0 + to: Playback.duration > 0 ? Playback.duration : 1 + value: Playback.shownPosition + fillColor: (!Playback.paused && !!Playback.now) ? Style.accent : Tokens.ink + onMoved: (v) => Playback.seekDrag = v + onCommitted: (v) => { Playback.seek(v); Playback.seekDrag = NaN; } + } + Text { + Layout.preferredWidth: 36 + horizontalAlignment: Text.AlignRight + text: Style.fmtTime(Playback.duration) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.DemiBold + } + } + // next + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 30 + spacing: 7 + Icon { name: "queue"; size: 12; color: Tokens.inkFaint } + Text { + Layout.fillWidth: true + text: root.nextUp ? ("NEXT · " + root.nextUp.title) : "QUEUE · END" + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.55 + elide: Text.ElideRight + } + } + } + + // LYRICS + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 8 + anchors.bottomMargin: 3 + visible: root.view === "lyrics" + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 27 + Layout.leftMargin: 5 + Layout.rightMargin: 5 + Text { text: "// LYRICS"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.85 } + Item { Layout.fillWidth: true } + Text { text: "AUTO FOLLOW"; color: Tokens.inkFaint; opacity: 0.68; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.85 } + } + LyricsPanel { + Layout.fillWidth: true + Layout.fillHeight: true + compact: true + visible: root.view === "lyrics" && root.active + } + } + + // QUEUE + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 8 + anchors.bottomMargin: 3 + visible: root.view === "queue" + spacing: 0 + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 27 + Layout.leftMargin: 5 + Layout.rightMargin: 5 + Text { text: "// QUEUE"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.85 } + Item { Layout.fillWidth: true } + Text { text: root.queueLeft + " NEXT"; color: Tokens.inkFaint; opacity: 0.68; font.family: Style.fontMono; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.85 } + } + TrackList { + Layout.fillWidth: true + Layout.fillHeight: true + items: (Playback.queue && Playback.queue.items) ? Playback.queue.items : [] + menu: false + canAdd: false + onActivated: (i) => Playback.playIndex(i) + } + } + } + + // footer: transport | volume + Item { + Layout.fillWidth: true + Layout.preferredHeight: 66 + Rectangle { + anchors { left: parent.left; right: parent.right; top: parent.top } + height: 1 + color: Tokens.lineSoft + } + RowLayout { + anchors.fill: parent + anchors.topMargin: 11 + spacing: 14 + + RowLayout { + spacing: 7 + IconButton { + icon: "shuffle"; iconSize: 16; diameter: 32 + active: !!(Playback.queue && Playback.queue.shuffle) + iconColor: active ? Tokens.bone : Tokens.inkMuted + onClicked: Playback.toggleShuffle() + } + IconButton { icon: "previous"; iconSize: 16; diameter: 32; onClicked: Playback.prev() } + IconButton { icon: Playback.paused ? "play" : "pause"; iconSize: 16; diameter: 32; primary: true; onClicked: Playback.togglePause() } + IconButton { icon: "next"; iconSize: 16; diameter: 32; onClicked: Playback.next() } + IconButton { + icon: (Playback.queue && Playback.queue.repeat === "one") ? "repeat-one" : "repeat" + iconSize: 16; diameter: 32 + active: !!(Playback.queue && Playback.queue.repeat && Playback.queue.repeat !== "off") + iconColor: active ? Tokens.bone : Tokens.inkMuted + onClicked: Playback.cycleRepeat() + } + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: 6 + IconButton { + icon: Playback.volume === 0 ? "volume-mute" : "volume" + iconSize: 16; diameter: 32 + onClicked: root.toggleMute() + } + Slider { + id: miniVol + Layout.preferredWidth: 96 + Layout.alignment: Qt.AlignVCenter + from: 0 + to: 100 + value: Playback.volume + onPressedChanged: Playback.volDrag = pressed + onMoved: (v) => { var iv = Math.round(v); Playback.volume = iv; Playback.setVolume(iv); } + onCommitted: (v) => { + var iv = Math.round(v); + Playback.volume = iv; + Playback.setVolume(iv); + Daemon.call("set_setting", { key: "volume", value: String(iv) }).catch(() => {}); + Playback.volDrag = false; + } + } + } + } + } + } + + // The 1 px frame, inside the rounded clip. + Rectangle { + anchors.fill: parent + radius: root.radius + color: "transparent" + border.width: 1 + border.color: Qt.rgba(Tokens.ink.r, Tokens.ink.g, Tokens.ink.b, 0.14) + } + } +} diff --git a/client/pages/AlbumPage.qml b/client/pages/AlbumPage.qml new file mode 100644 index 0000000..2db5a5d --- /dev/null +++ b/client/pages/AlbumPage.qml @@ -0,0 +1,363 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/ids.js" as Ids + +// The album page, ported from ui/src/routes/album/[id]/+page.svelte. get_album(id) once; the whole +// page is one TrackList scroller whose header carries the PageHero (cover, eyebrow, title, meta, +// Play primary, the library-save heart, the ⋯ menu) plus a slim controls row (Shuffle, filter) and +// the description, and whose footer carries the card carousels YouTube hangs under the tracks. +// Play/Shuffle seed the queue with the full album (never just the filtered rows); a local album has +// no YouTube identity, so save/radio/share hide for it. Data flows are unchanged. +Item { + id: page + + readonly property var params: Router.current ? Router.current.params : ({}) + readonly property string albumId: page.params && page.params.id ? page.params.id : "" + + property var album: null + property bool loading: true + property string errorMsg: "" + property string query: "" + property bool expanded: false + property bool savingLibrary: false + + readonly property bool isLocal: Ids.isLocalId(page.albumId) + readonly property var shown: { + if (!page.album || !page.album.items) + return []; + var q = page.query.trim().toLowerCase(); + if (!q) + return page.album.items; + return page.album.items.filter((t) => ( + (t.title && t.title.toLowerCase().indexOf(q) >= 0) + || (t.artists && t.artists.toLowerCase().indexOf(q) >= 0) + || (t.album && t.album.toLowerCase().indexOf(q) >= 0))); + } + readonly property bool inLibrary: !!(page.album && page.album.inLibrary) + + onParamsChanged: page.load() + Component.onCompleted: page.load() + + function asItem() { + return { + kind: page.albumId.indexOf("LOCALARTIST:") === 0 ? "artist" : "album", + id: page.albumId, + title: page.album ? page.album.title : "Album", + subtitle: page.album ? page.album.artist : "", + thumbnail: page.album ? page.album.thumbnail : "", + explicit: page.album ? page.album.explicit : false + }; + } + + function metaLine() { + if (!page.album) + return ""; + var parts = []; + if (page.album.artist) + parts.push(page.album.artist); + // secondSubtitle already carries the song count + duration; only synthesise a track count + // when it is absent, so we never print "1 song · 1 track". + if (page.album.secondSubtitle) { + parts.push(page.album.secondSubtitle); + } else { + var n = page.album.items ? page.album.items.length : 0; + if (n) + parts.push(n + (n === 1 ? " track" : " tracks")); + } + return parts.join(" \u00b7 "); + } + + function load() { + if (!page.albumId) + return; + page.loading = true; + page.errorMsg = ""; + page.query = ""; + page.expanded = false; + var reqId = page.albumId; + Daemon.call("get_album", { id: page.albumId }) + .then((a) => { + if (page.albumId !== reqId) + return; + page.album = a; + page.loading = false; + Qt.callLater(page.scrollTop); + }) + .catch((e) => { + if (page.albumId !== reqId) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + function scrollTop() { + if (body.visible && body.view) + body.view.positionViewAtBeginning(); + } + + function playAll(start) { + if (!page.album) + return; + var at = start === null ? null : page.album.items.indexOf(page.shown[start]); + var recent = page.asItem(); + Daemon.call("play_playlist", { + items: page.album.items, + start: at === -1 ? null : at, + sourceId: page.album.playlistId ? page.album.playlistId : undefined, + sourceName: page.album.title + }).then(() => Personal.noteRecent(recent)) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function shuffle() { + if (!page.album || !page.album.items.length) + return; + var recent = page.asItem(); + Daemon.call("play_playlist", { + items: page.album.items, + start: null, + sourceId: page.album.playlistId ? page.album.playlistId : undefined, + sourceName: page.album.title, + shuffle: true + }).then(() => Personal.noteRecent(recent)) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function queueAlbum(next) { + if (!page.album || !page.album.items.length) + return; + Daemon.call(next ? "play_next" : "add_to_queue", { + items: page.album.items, + from: page.album.title, + continuation: page.album.continuation + }).then(() => Playback.toast(next ? "Playing next" : "Added to queue", "success")) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not queue", "error")); + } + function radio() { + if (!page.album || !page.album.playlistId) + return; + Playback.toast("Starting radio…", "info"); + Daemon.call("start_radio", { kind: "playlist", id: page.album.playlistId, name: page.album.title }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not start radio", "error")); + } + function toggleLibrary() { + if (!page.album || page.savingLibrary || page.isLocal) + return; + if (!page.album.playlistId || !(Playback.auth && Playback.auth.signedIn)) { + Playback.toast("Sign in to save albums to your library", "info"); + return; + } + var next = !page.inLibrary; + page.album = Object.assign({}, page.album, { inLibrary: next }); + page.savingLibrary = true; + Daemon.call("set_album_saved", { playlistId: page.album.playlistId, saved: next }) + .then(() => { page.savingLibrary = false; Playback.toast(next ? "Saved to library" : "Removed from library", "success"); }) + .catch((e) => { + page.album = Object.assign({}, page.album, { inLibrary: !next }); + page.savingLibrary = false; + Playback.toast((e && e.message) ? e.message : "Could not save", "error"); + }); + } + function share() { + var url = "https://music.youtube.com/browse/" + encodeURIComponent(page.albumId); + Quickshell.execDetached(["sh", "-c", "printf %s \"$1\" | wl-copy", "sh", url]); + Playback.toast("Link copied", "success"); + } + function showMore(section) { + Router.push("list", { id: section.moreBrowseId, title: section.title, params: section.moreParams }); + } + + Text { + anchors.centerIn: parent + visible: page.loading || page.errorMsg !== "" + text: page.loading ? "Loading album…" : page.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + TrackList { + id: body + anchors.fill: parent + visible: !page.loading && page.errorMsg === "" && page.album !== null + items: page.shown + hideThumb: true + showHeader: true + showAlbum: false + showPlays: true + canAdd: !page.isLocal + source: page.album ? page.album.title : "" + onActivated: (i) => page.playAll(i) + header: albumHeader + footer: albumFooter + } + + Component { + id: albumHeader + Item { + width: body.view.width + implicitHeight: headerCol.implicitHeight + Style.sp(3) + + ColumnLayout { + id: headerCol + width: parent.width + spacing: Style.sp(4) + + PageHero { + id: hero + Layout.fillWidth: true + eyebrow: (page.album && page.album.subtitle) ? page.album.subtitle : "Album" + title: (page.album && page.album.title) ? page.album.title : "Album" + meta: page.metaLine() + art: (page.album && page.album.thumbnail) ? page.album.thumbnail : "" + placeholderIcon: "cd" + primaryLabel: "Play" + likeable: !page.isLocal + liked: page.inLibrary + showMore: true + onPrimary: page.playAll(null) + onLike: page.toggleLibrary() + onMore: { + var p = hero.mapToItem(page, Style.sp(44), hero.height - Style.sp(8)); + albumMenu.openAt(p.x, p.y); + } + } + + // controls: Shuffle, filter + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Btn { + text: "Shuffle" + icon: "shuffle" + enabled: !!(page.album && page.album.items && page.album.items.length) + onClicked: page.shuffle() + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: Style.sp(52) + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.ctlH + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: albumFilter.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: albumFilter + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: page.query + onTextChanged: page.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: albumFilter.text.length === 0 + text: "Search this album" + color: Tokens.inkFaint + font: albumFilter.font + } + } + } + } + } + + // description (collapsible) + ColumnLayout { + Layout.fillWidth: true + visible: !!(page.album && page.album.description) + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: (page.album && page.album.description) ? page.album.description : "" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + maximumLineCount: page.expanded ? 999 : 2 + elide: Text.ElideRight + } + Text { + text: page.expanded ? "LESS" : "MORE" + color: moreHover.hovered ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + HoverHandler { id: moreHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: page.expanded = !page.expanded } + } + } + } + } + } + + Component { + id: albumFooter + Item { + width: body.view.width + implicitHeight: (page.album && page.album.sections && page.album.sections.length) + ? (footerCol.implicitHeight + Style.sp(24)) : Style.sp(20) + ColumnLayout { + id: footerCol + width: parent.width + y: Style.sp(6) + spacing: Style.sp(9) + visible: !!(page.album && page.album.sections && page.album.sections.length) + Repeater { + model: (page.album && page.album.sections) ? page.album.sections : [] + delegate: Shelf { + required property var modelData + Layout.fillWidth: true + section: modelData + } + } + } + } + } + + // header ⋯ menu (album-level). Custom items operate on the whole album; "Add to shortcuts" is + // the personal-store pin the PersonalStore agent wires (Task 4b, Step 3). + Menu { + id: albumMenu + customItems: page.buildAlbumMenu() + } + AddToPlaylist { id: albumPicker; blurSource: body } + + function buildAlbumMenu() { + var out = []; + out.push({ icon: "arrow-up", label: "Play next", danger: false, act: () => page.queueAlbum(true) }); + out.push({ icon: "queue", label: "Add to queue", danger: false, act: () => page.queueAlbum(false) }); + if (!page.isLocal && page.album && page.album.items && page.album.items.length) + out.push({ icon: "download", label: Downloads.addingAlbum ? "Adding album…" : "Download album", + danger: false, act: () => Downloads.enqueueAlbum(page.album) }); + if (!page.isLocal && page.album && page.album.playlistId) + out.push({ icon: "radio", label: "Start radio", danger: false, act: () => page.radio() }); + if (page.album && page.album.artistId) + out.push({ icon: "artists", label: "Go to artist", danger: false, + act: () => Router.push("artist", { id: page.album.artistId }) }); + if (!page.isLocal) + out.push({ icon: "add", label: "Save to playlist", danger: false, + act: () => albumPicker.openWith(page.album ? page.album.items : []) }); + out.push({ icon: "dashboard", label: "Add to shortcuts", danger: false, act: () => page.addAlbumShortcut() }); + if (!page.isLocal) + out.push({ icon: "link", label: "Share", danger: false, act: () => page.share() }); + return out; + } + + // Personal-store action (Task 4b): pin this album to the Home shortcuts grid. Wired by PersonalStore. + function addAlbumShortcut() { + Playback.toast(Personal.addPick(page.asItem()) ? "Added to shortcuts" : "Already in shortcuts", "success"); + } +} diff --git a/client/pages/ArtistPage.qml b/client/pages/ArtistPage.qml new file mode 100644 index 0000000..b15032f --- /dev/null +++ b/client/pages/ArtistPage.qml @@ -0,0 +1,720 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The artist page, ported from ui/src/routes/artist/[id]/+page.svelte. get_artist(id) once; the page +// is one TrackList whose header is a PageHero (round artist art, name, counts, Play/Shuffle/Radio/ +// Subscribe) followed by the "Popular" heading, whose rows are the five top songs (Show all opens the +// full top-songs playlist), and whose footer is Releases — a CardGrid with a Singles/Albums/EPs chip +// filter — and the remaining carousels. Shuffle prefers the full top-songs playlist (topSongsId) so it +// covers more than the visible rows. Data flows are unchanged. +Item { + id: page + + readonly property var params: Router.current ? Router.current.params : ({}) + readonly property string artistId: page.params && page.params.id ? page.params.id : "" + + property var artist: null + property bool loading: true + property string errorMsg: "" + property bool expanded: false + property bool subscribed: false + property bool subBusy: false + property int releaseTab: 0 + + readonly property bool signedIn: !!(Playback.auth && Playback.auth.signedIn) + readonly property var sections: { + if (!page.artist || !page.artist.sections) + return []; + return page.artist.sections.filter((s) => !/music\s*videos?|video\s+for\s+you/i.test(s.title)); + } + readonly property var releaseSections: page.sections.filter((s) => /album|single|\bep/i.test(s.title)) + readonly property var otherSections: page.sections.filter((s) => !/album|single|\bep/i.test(s.title)) + readonly property var topSongs: (page.artist && page.artist.topSongs) ? page.artist.topSongs : [] + readonly property var popular: page.topSongs.slice(0, 5) + + // SoundCloud user page (the Orange look): get_artist for an sc:user id returns kind:"soundcloud" + // with the user's own tracks, albums, playlists and likes rather than the YouTube sections. + readonly property bool isSC: !!(page.artist && page.artist.kind === "soundcloud") + readonly property var scTracks: (page.artist && page.artist.tracks) || [] + readonly property var scAlbums: (page.artist && page.artist.albums) || [] + readonly property var scPlaylists: (page.artist && page.artist.playlists) || [] + readonly property var scLikes: (page.artist && page.artist.likes) || [] + + onParamsChanged: page.load() + Component.onCompleted: page.load() + + function asItem() { + return { + kind: "artist", + id: page.artistId, + title: page.artist ? page.artist.name : "Artist", + subtitle: page.artist ? page.artist.subscribers : "", + thumbnail: page.artist ? page.artist.thumbnail : "" + }; + } + + function metaLine() { + var a = page.artist; + if (!a) + return ""; + var parts = []; + if (a.subscribers) parts.push(a.subscribers); + if (a.monthlyListeners) parts.push(a.monthlyListeners); + return parts.join(" \u00b7 "); + } + + function scMetaLine() { + var a = page.artist; + if (!a) + return ""; + var parts = []; + if (a.city) parts.push(String(a.city)); + var f = Style.fmtCount(a.followers); + if (f) parts.push(f + " followers"); + var t = Style.fmtCount(a.trackCount); + if (t) parts.push(t + " tracks"); + return parts.join(" \u00b7 "); + } + function playSC(start) { + if (!page.scTracks.length) + return; + Daemon.call("play_playlist", { items: page.scTracks, start: start, sourceName: page.artist ? page.artist.name : "" }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function shuffleSC() { + if (!page.scTracks.length) + return; + Daemon.call("play_playlist", { items: page.scTracks, start: null, shuffle: true, sourceName: page.artist ? page.artist.name : "" }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function playLikes(start) { + if (!page.scLikes.length) + return; + Daemon.call("play_playlist", { items: page.scLikes, start: start, sourceName: (page.artist ? page.artist.name : "") + " \u00b7 Likes" }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + function load() { + if (!page.artistId) + return; + page.loading = true; + page.errorMsg = ""; + page.expanded = false; + page.releaseTab = 0; + var reqId = page.artistId; + Daemon.call("get_artist", { id: page.artistId }) + .then((a) => { + if (page.artistId !== reqId) + return; + page.artist = a; + page.subscribed = !!a.subscribed; + page.loading = false; + Qt.callLater(page.scrollTop); + }) + .catch((e) => { + if (page.artistId !== reqId) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + function scrollTop() { + if (body.visible && body.view) + body.view.positionViewAtBeginning(); + } + + function playTop(start) { + if (!page.artist || !page.topSongs.length) + return; + Daemon.call("play_playlist", { + items: page.artist.topSongs, + start: start, + sourceName: page.artist.name + }).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function shuffle() { + if (!page.artist) + return; + var pid = page.artist.topSongsId; + if (pid) { + Daemon.call("get_playlist", { id: pid }) + .then((pl) => { + if (pl.items && pl.items.length) + return Daemon.call("play_playlist", { + items: pl.items, start: null, sourceId: pid, + sourceName: page.artist.name, shuffle: true, continuation: pl.continuation + }); + return page.shuffleVisible(); + }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + return; + } + page.shuffleVisible(); + } + function shuffleVisible() { + if (!page.artist || !page.topSongs.length) + return Promise.resolve(); + return Daemon.call("play_playlist", { + items: page.artist.topSongs, start: null, sourceName: page.artist.name, shuffle: true + }); + } + function radio() { + Playback.toast("Starting radio…", "info"); + Daemon.call("start_radio", { kind: "artist", id: page.artistId, name: page.artist ? page.artist.name : null }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not start radio", "error")); + } + function toggleSub() { + if (!page.artist || page.subBusy) + return; + if (!page.signedIn) { + Playback.toast("Sign in to subscribe", "info"); + return; + } + var next = !page.subscribed; + page.subBusy = true; + page.subscribed = next; + Daemon.call("subscribe", { channelId: page.artist.channelId, subscribed: next }) + .then(() => { page.subBusy = false; Playback.toast(next ? ("Subscribed to " + (page.artist.name || "")) : "Unsubscribed", "success"); }) + .catch((e) => { + page.subscribed = !next; + page.subBusy = false; + Playback.toast((e && e.message) ? e.message : "Could not subscribe", "error"); + }); + } + function share() { + var url = "https://music.youtube.com/channel/" + encodeURIComponent(page.artistId); + Quickshell.execDetached(["sh", "-c", "printf %s \"$1\" | wl-copy", "sh", url]); + Playback.toast("Link copied", "success"); + } + function showMore(section) { + Router.push("list", { id: section.moreBrowseId, title: section.title, params: section.moreParams }); + } + function seeAllTop() { + if (page.artist && page.artist.topSongsId) + Router.push("playlist", { id: page.artist.topSongsId, title: "Top songs" }); + } + function displaySectionTitle(title) { + var name = page.artist && page.artist.name ? page.artist.name.trim() : ""; + if (name && title.trim().toLowerCase() === name.toLowerCase()) + return "More like " + name; + return title; + } + + Text { + anchors.centerIn: parent + visible: page.loading || page.errorMsg !== "" + text: page.loading ? "Loading artist…" : page.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + TrackList { + id: body + anchors.fill: parent + visible: !page.loading && page.errorMsg === "" && page.artist !== null && !page.isSC + items: page.popular + showHeader: true + showAlbum: true + showPlays: true + source: page.artist ? page.artist.name : "" + onActivated: (i) => page.playTop(i) + header: artistHeader + footer: artistFooter + } + + // ── SoundCloud user (the Orange look) ─────────────────────────────────────────────────── + // A blurred banner with the round avatar overlapping its foot, the name + verified tick and a + // city · followers · tracks line, then two columns: the user's tracks on the left, and the + // albums / playlists / likes rail (300 px) on the right. + component ScMiniCard: Item { + id: mc + property var card: null + property bool playable: false // a liked track plays; an album/playlist routes + signal activated() + Layout.fillWidth: true + implicitHeight: Style.sp(14) + Style.sp(2) + + Rectangle { + anchors.fill: parent + radius: Style.radius + color: mcHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(1) + anchors.rightMargin: Style.sp(1) + spacing: Style.sp(2) + Artwork { + Layout.alignment: Qt.AlignVCenter + url: (mc.card && mc.card.thumbnail) ? mc.card.thumbnail : "" + px: Style.sp(14) + placeholderIcon: mc.playable ? "music" : "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: (mc.card && mc.card.title) ? mc.card.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: text !== "" + text: mc.card ? (mc.card.subtitle || mc.card.artists || "") : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.xs + elide: Text.ElideRight + } + } + } + HoverHandler { id: mcHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: mc.activated() } + } + + Flickable { + id: scView + anchors.fill: parent + visible: !page.loading && page.errorMsg === "" && page.isSC + clip: true + contentWidth: width + contentHeight: scCol.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: scCol + width: scView.width + spacing: Style.sp(5) + + // banner + avatar + identity + Item { + id: scHead + Layout.fillWidth: true + readonly property int bannerH: Style.sp(50) // 200 + readonly property int avatarPx: Style.sp(30) // 120 + readonly property int pad: Style.pagePad + implicitHeight: Math.max(banner.height + scHead.avatarPx / 2, + banner.height + Style.sp(2) + identity.implicitHeight) + Style.sp(3) + + Item { + id: banner + width: parent.width + height: scHead.bannerH + clip: true + Rectangle { anchors.fill: parent; color: Tokens.paperLift } + Image { + id: bannerImg + anchors.fill: parent + source: (page.artist && page.artist.banner) ? Style.thumb(page.artist.banner, 1200) : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + } + MultiEffect { + anchors.fill: parent + source: bannerImg + visible: Style.blurEnabled && bannerImg.status === Image.Ready + blurEnabled: true + blur: 1.0 + blurMax: 48 + saturation: -0.2 + opacity: 0.55 + } + // fade the banner to paper so the avatar and name sit on the page + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.35) } + GradientStop { position: 0.6; color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.55) } + GradientStop { position: 1.0; color: Tokens.paper } + } + } + Hairline { anchors.bottom: parent.bottom; width: parent.width; height: 1 } + } + + // round avatar overlapping the banner's bottom edge + Item { + x: scHead.pad + y: banner.height - scHead.avatarPx / 2 + width: scHead.avatarPx + height: scHead.avatarPx + Artwork { + anchors.fill: parent + url: (page.artist && page.artist.thumbnail) ? page.artist.thumbnail : "" + px: scHead.avatarPx + round: true + placeholderIcon: "user" + } + Rectangle { + anchors.fill: parent + radius: width / 2 + color: "transparent" + border.width: 2 + border.color: Tokens.paper + } + } + + ColumnLayout { + id: identity + x: scHead.pad + scHead.avatarPx + Style.sp(4) + y: banner.height + Style.sp(2) + width: Math.max(Style.sp(40), scHead.width - x - scHead.pad) + spacing: Style.sp(1) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { + Layout.maximumWidth: identity.width - Style.sp(6) + text: (page.artist && page.artist.name) ? page.artist.name : "SoundCloud" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + elide: Text.ElideRight + } + Icon { + visible: !!(page.artist && page.artist.verified) + Layout.alignment: Qt.AlignVCenter + name: "check-circle" + size: Style.fs.md + color: Style.providerColors.soundcloud + } + Item { Layout.fillWidth: true } + } + Text { + Layout.fillWidth: true + visible: text !== "" + text: page.scMetaLine() + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: !!(page.artist && page.artist.description) + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: (page.artist && page.artist.description) ? page.artist.description : "" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + maximumLineCount: page.expanded ? 999 : 3 + elide: Text.ElideRight + } + Text { + text: page.expanded ? "LESS" : "MORE" + color: scDescHover.hovered ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + HoverHandler { id: scDescHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: page.expanded = !page.expanded } + } + } + } + } + + // play / shuffle + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.pagePad + Layout.rightMargin: Style.pagePad + spacing: Style.sp(3) + Btn { text: "Play"; icon: "play"; primary: true; enabled: page.scTracks.length > 0; onClicked: page.playSC(0) } + Btn { text: "Shuffle"; icon: "shuffle"; enabled: page.scTracks.length > 0; onClicked: page.shuffleSC() } + Item { Layout.fillWidth: true } + } + + // two columns: tracks (left) + albums/playlists/likes rail (right) + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.pagePad + Layout.rightMargin: Style.pagePad + Layout.bottomMargin: Style.sp(20) + Layout.alignment: Qt.AlignTop + spacing: Style.sp(6) + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 0 + SectionHeading { Layout.fillWidth: true; Layout.bottomMargin: Style.sp(1); title: "Tracks" } + Repeater { + model: page.scTracks + delegate: Item { + id: trackWrap + required property var modelData + required property int index + Layout.fillWidth: true + implicitHeight: Style.rowH + TrackRow { + anchors.fill: parent + song: trackWrap.modelData + index: trackWrap.index + showPlays: true + menu: false + active: !!(Playback.now && trackWrap.modelData && Playback.now.videoId === trackWrap.modelData.video_id) + onPlay: page.playSC(trackWrap.index) + } + } + } + Text { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + visible: page.scTracks.length === 0 + text: "No public tracks." + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + + ColumnLayout { + Layout.preferredWidth: Style.sp(75) // 300 + Layout.maximumWidth: Style.sp(75) + Layout.alignment: Qt.AlignTop + spacing: Style.sp(4) + + ColumnLayout { + Layout.fillWidth: true + visible: page.scAlbums.length > 0 + spacing: Style.sp(0.5) + SectionHeading { Layout.fillWidth: true; Layout.bottomMargin: Style.sp(1); title: "Albums from this user" } + Repeater { + model: page.scAlbums + delegate: ScMiniCard { + required property var modelData + card: modelData + onActivated: Router.push(modelData.kind || "album", { id: modelData.id, title: modelData.title }) + } + } + } + ColumnLayout { + Layout.fillWidth: true + visible: page.scPlaylists.length > 0 + spacing: Style.sp(0.5) + SectionHeading { Layout.fillWidth: true; Layout.bottomMargin: Style.sp(1); title: "Playlists" } + Repeater { + model: page.scPlaylists + delegate: ScMiniCard { + required property var modelData + card: modelData + onActivated: Router.push(modelData.kind || "playlist", { id: modelData.id, title: modelData.title }) + } + } + } + ColumnLayout { + Layout.fillWidth: true + visible: page.scLikes.length > 0 + spacing: Style.sp(0.5) + SectionHeading { Layout.fillWidth: true; Layout.bottomMargin: Style.sp(1); title: "Likes" } + Repeater { + model: page.scLikes + delegate: ScMiniCard { + required property var modelData + required property int index + card: modelData + playable: true + onActivated: page.playLikes(index) + } + } + } + } + } + } + } + + Component { + id: artistHeader + Item { + width: body.view.width + implicitHeight: headerCol.implicitHeight + Style.sp(3) + + ColumnLayout { + id: headerCol + width: parent.width + spacing: Style.sp(4) + + PageHero { + id: hero + Layout.fillWidth: true + round: true + eyebrow: "Artist" + title: (page.artist && page.artist.name) ? page.artist.name : "Artist" + meta: page.metaLine() + art: (page.artist && page.artist.thumbnail) ? page.artist.thumbnail : "" + placeholderIcon: "user" + primaryLabel: "Play" + likeable: false + showMore: true + onPrimary: page.playTop(null) + onMore: { + var p = hero.mapToItem(page, Style.sp(44), hero.height - Style.sp(8)); + artistMenu.openAt(p.x, p.y); + } + } + + // controls: Shuffle, Radio, Subscribe + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Btn { + text: "Shuffle" + icon: "shuffle" + enabled: page.topSongs.length > 0 + onClicked: page.shuffle() + } + Btn { + text: "Radio" + icon: "radio" + onClicked: page.radio() + } + Btn { + text: !page.signedIn ? "Save to library" : (page.subscribed ? "Subscribed" : "Subscribe") + icon: page.subscribed ? "check-circle" : "add" + enabled: !page.subBusy + onClicked: page.toggleSub() + } + Item { Layout.fillWidth: true } + } + + // description (collapsible) + ColumnLayout { + Layout.fillWidth: true + visible: !!(page.artist && page.artist.description) + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: (page.artist && page.artist.description) ? page.artist.description : "" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + maximumLineCount: page.expanded ? 999 : 2 + elide: Text.ElideRight + } + Text { + text: page.expanded ? "LESS" : "MORE" + color: descHover.hovered ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + HoverHandler { id: descHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: page.expanded = !page.expanded } + } + } + + // "Popular" heading over the rows + SectionHeading { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + visible: page.popular.length > 0 + title: "Popular" + more: !!(page.artist && page.artist.topSongsId) + onMoreClicked: page.seeAllTop() + } + } + } + } + + Component { + id: artistFooter + Item { + width: body.view.width + implicitHeight: footerCol.implicitHeight + Style.sp(24) + ColumnLayout { + id: footerCol + width: parent.width + y: Style.sp(6) + spacing: Style.sp(9) + + // Releases: chip filter + card grid + ColumnLayout { + Layout.fillWidth: true + visible: page.releaseSections.length > 0 + spacing: Style.sp(4) + SectionHeading { Layout.fillWidth: true; title: "Releases" } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Repeater { + model: page.releaseSections + delegate: Chip { + required property var modelData + required property int index + text: modelData.title + active: page.releaseTab === index + onClicked: page.releaseTab = index + } + } + Item { Layout.fillWidth: true } + } + Grid { + id: relGrid + Layout.fillWidth: true + readonly property int cols: Math.max(2, Math.floor(width / (Style.cardW + Style.sp(4)))) + readonly property real cw: relGrid.cols > 0 ? (width - (relGrid.cols - 1) * Style.sp(4)) / relGrid.cols : Style.cardW + readonly property var relItems: (page.releaseSections[page.releaseTab] && page.releaseSections[page.releaseTab].items) + ? page.releaseSections[page.releaseTab].items : [] + columns: relGrid.cols + columnSpacing: Style.sp(4) + rowSpacing: Style.sp(6) + Repeater { + model: relGrid.relItems + delegate: MediaCard { + required property var modelData + item: modelData + cardWidth: relGrid.cw + } + } + } + } + + // remaining carousels (related artists, featured on, …) + Repeater { + model: page.otherSections + delegate: Shelf { + required property var modelData + Layout.fillWidth: true + section: { + return { title: page.displaySectionTitle(modelData.title), items: modelData.items, + moreBrowseId: modelData.moreBrowseId, moreParams: modelData.moreParams }; + } + } + } + } + } + } + + Menu { + id: artistMenu + customItems: [ + { icon: "dashboard", label: "Add to shortcuts", danger: false, act: () => page.addArtistShortcut() }, + { icon: "link", label: "Share", danger: false, act: () => page.share() } + ] + } + + // Personal-store action (Task 4b): pin this artist to the Home shortcuts grid. Wired by PersonalStore. + function addArtistShortcut() { + Playback.toast(Personal.addPick(page.asItem()) ? "Added to shortcuts" : "Already in shortcuts", "success"); + } +} diff --git a/client/pages/DiagnosticsPage.qml b/client/pages/DiagnosticsPage.qml new file mode 100644 index 0000000..8b37e81 --- /dev/null +++ b/client/pages/DiagnosticsPage.qml @@ -0,0 +1,282 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The diagnostics surface (Settings ▸ Diagnostics): everything that went wrong, both halves of +// the app in one list. The daemon records its own warn/error tracing, every failed RPC, panics, +// and the client's forwarded failures into a ring + a rotating file; this page reads them back +// through `get_logs` with level/source/text filters. Refresh is manual (no polling, the same +// rule as software updates). "Open folder" reveals the file for attaching to a bug report; +// "Copy all" puts a plain-text dump on the clipboard for the same reason. +ColumnLayout { + id: root + spacing: Style.sp(3) + + property var log: null // the get_logs snapshot + property bool loading: false + property string error: "" + property string level: "warn" // all | warn | error + property string source: "all" // all | daemon | client + property string query: "" + readonly property var entries: (root.log && root.log.entries) ? root.log.entries : [] + + readonly property var levels: [ + { k: "all", l: "Everything" }, + { k: "warn", l: "Warnings +" }, + { k: "error", l: "Errors" } + ] + readonly property var sources: [ + { k: "all", l: "Both" }, + { k: "daemon", l: "Daemon" }, + { k: "client", l: "App" } + ] + + function fmtTime(ts) { + if (!ts) + return ""; + var d = new Date(ts); + function p(n) { return (n < 10 ? "0" : "") + n; } + return p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()); + } + function fmtBytes(n) { + if (!n) + return "0 B"; + if (n < 1024) + return n + " B"; + if (n < 1024 * 1024) + return Math.round(n / 1024) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; + } + + function refresh() { + root.loading = true; + root.error = ""; + Daemon.call("get_logs", { + level: root.level, + source: root.source, + query: root.query, + limit: 500 + }).then((d) => { + root.log = d; + root.loading = false; + }).catch((e) => { + root.loading = false; + root.error = (e && e.message) ? e.message : "Could not read the log"; + }); + } + + function copyAll() { + var lines = []; + for (var i = 0; i < root.entries.length; i++) { + var e = root.entries[i]; + lines.push(new Date(e.ts).toISOString() + " [" + e.level + "] " + e.source + " " + e.target + ": " + e.message); + } + clip.text = lines.join("\n"); + clip.copy(); + Playback.toast(lines.length + " log lines copied", "success"); + } + + Component.onCompleted: root.refresh() + TextEdit { id: clip; visible: false; width: 0; height: 0 } + + // ── header ──────────────────────────────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: "Diagnostics" + color: Tokens.ink; font.family: Style.fontDisplay; font.pixelSize: Style.fs.lg + } + Text { + Layout.fillWidth: true + text: "Everything Ryotunes and its background service got wrong, newest first — including failures from before this session. Use it when something \"works for everyone else but me\"." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + + // ── summary strip ───────────────────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + text: root.log ? (root.log.errors + " errors · " + root.log.warnings + " warnings recorded") : "—" + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium + } + Text { + Layout.fillWidth: true + text: root.log && root.log.path ? ("File: " + root.log.path + " (" + root.fmtBytes(root.log.fileBytes) + ")") : "Log file not available" + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + elide: Text.ElideMiddle + } + } + Pill { + Layout.alignment: Qt.AlignVCenter + label: "Open folder" + icon: "folder" + onClicked: Daemon.call("open_logs_folder").catch((e) => Playback.toast((e && e.message) ? e.message : "Could not open the folder", "error")) + } + Pill { + Layout.alignment: Qt.AlignVCenter + label: "Copy all" + icon: "link" + enabled: root.entries.length > 0 + onClicked: root.copyAll() + } + Pill { + Layout.alignment: Qt.AlignVCenter + label: root.loading ? "Refreshing…" : "Refresh" + icon: "loading" + enabled: !root.loading + onClicked: root.refresh() + } + } + + // ── filters ─────────────────────────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + + RowLayout { + spacing: Style.sp(1) + Repeater { + model: root.levels + delegate: Pill { + required property var modelData + label: modelData.l + active: root.level === modelData.k + onClicked: { root.level = modelData.k; root.refresh(); } + } + } + } + RowLayout { + spacing: Style.sp(1) + Repeater { + model: root.sources + delegate: Pill { + required property var modelData + label: modelData.l + active: root.source === modelData.k + onClicked: { root.source = modelData.k; root.refresh(); } + } + } + } + Rectangle { + Layout.fillWidth: true + Layout.minimumWidth: Style.sp(30) + implicitHeight: Style.ctlH + radius: Style.radius + color: Tokens.paperLift + border.width: searchField.activeFocus ? 2 : 1 + border.color: searchField.activeFocus ? Tokens.ink : Tokens.line + TextInput { + id: searchField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + onAccepted: root.refresh() + Accessible.name: "Search log messages" + } + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.sp(2) + visible: searchField.text === "" + text: "Search messages…" + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + Pill { + label: "Search" + icon: "search" + enabled: !root.loading + onClicked: root.refresh() + } + } + + // ── body ────────────────────────────────────────────────────────────────────────────── + Text { + Layout.fillWidth: true + visible: root.error !== "" + text: root.error + color: Style.alert; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + visible: !root.loading && root.error === "" && root.entries.length === 0 + text: "Nothing here. No failures have been recorded with these filters — that is good news." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + + // Bounded list: the page lives inside the settings scroller, so cap the rows and let the + // filters narrow instead of growing the settings page to thousands of items. + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Repeater { + model: Math.min(root.entries.length, 200) + delegate: RowLayout { + id: row + required property int index + readonly property var entry: root.entries[index] + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + + Rectangle { + Layout.alignment: Qt.AlignTop + Layout.topMargin: Style.sp(0.5) + width: 8 + height: 8 + radius: 4 + color: row.entry && row.entry.level === "error" ? Style.alert : Tokens.sun + } + Text { + Layout.alignment: Qt.AlignTop + text: root.fmtTime(row.entry ? row.entry.ts : 0) + color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + } + Text { + Layout.alignment: Qt.AlignTop + text: row.entry ? row.entry.source + " " + row.entry.target : "" + color: Tokens.inkDim; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + Layout.maximumWidth: Style.sp(46) + elide: Text.ElideMiddle + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: row.entry ? row.entry.message : "" + textFormat: Text.PlainText + color: Tokens.ink; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + wrapMode: Text.WordWrap + maximumLineCount: 4 + elide: Text.ElideRight + Accessible.role: Accessible.StaticText + Accessible.name: text + } + } + } + } + Text { + Layout.fillWidth: true + visible: root.entries.length > 200 + text: "Showing the newest 200 of " + root.entries.length + " matching lines — narrow with the filters or open the file." + color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } +} diff --git a/client/pages/DownloadSettings.qml b/client/pages/DownloadSettings.qml new file mode 100644 index 0000000..2fac4d2 --- /dev/null +++ b/client/pages/DownloadSettings.qml @@ -0,0 +1,277 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Ryoku.Ui.Singletons +import "../" +import "../components" + +ColumnLayout { + id: root + spacing: Style.sp(4) + + property var saved: null + property string pathInput: "" + property string formatInput: "original" + property int workersInput: 1 + property bool organizeInput: true + property bool metadataInput: true + property bool loading: false + property bool saving: false + property string error: "" + readonly property bool dirty: !!saved && (pathInput !== saved.path || formatInput !== saved.format + || workersInput !== saved.workers || organizeInput !== saved.organizeByArtist || metadataInput !== saved.embedMetadata) + + function accept(settings) { + root.saved = settings; + root.pathInput = settings.path; + root.formatInput = settings.format; + root.workersInput = settings.workers; + root.organizeInput = settings.organizeByArtist; + root.metadataInput = settings.embedMetadata; + } + function load() { + root.loading = true; + root.error = ""; + Daemon.call("get_download_settings").then((settings) => { + root.accept(settings); + root.loading = false; + }).catch((e) => { root.loading = false; root.error = e.message || String(e); }); + } + function save() { + if (!root.saved || root.saving || !Daemon.connected) return; + root.saving = true; + root.error = ""; + Daemon.call("set_download_settings", { settings: { + path: root.pathInput.trim(), format: root.formatInput, workers: root.workersInput, + organizeByArtist: root.organizeInput, embedMetadata: root.metadataInput + } }).then((settings) => { + root.accept(settings); + root.saving = false; + Playback.toast("Download preferences saved", "success"); + }).catch((e) => { root.saving = false; root.error = e.message || String(e); }); + } + Component.onCompleted: root.load() + Connections { + target: Daemon + function onConnectedChanged(): void { + if (Daemon.connected && !root.saved && !root.loading) root.load(); + } + } + + component Action: Btn { + id: action + activeFocusOnTab: enabled && visible + Keys.onSpacePressed: (event) => { action.clicked(); event.accepted = true; } + Keys.onReturnPressed: (event) => { action.clicked(); event.accepted = true; } + Accessible.onPressAction: action.clicked() + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Style.radius + color: "transparent" + border.width: action.activeFocus ? 2 : 0 + border.color: Tokens.ink + } + } + component Choice: Chip { + id: choice + property string accessibleLabel: text + activeFocusOnTab: enabled && visible + Accessible.role: Accessible.RadioButton + Accessible.name: accessibleLabel + Accessible.checkable: true + Accessible.checked: active + Accessible.onPressAction: choice.clicked() + Keys.onSpacePressed: (event) => { choice.clicked(); event.accepted = true; } + Keys.onReturnPressed: (event) => { choice.clicked(); event.accepted = true; } + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: height / 2 + color: "transparent" + border.width: choice.activeFocus ? 2 : 0 + border.color: Tokens.ink + } + } + + SectionHeading { Layout.fillWidth: true; title: "Downloads"; mark: "保存" } + Text { + Layout.fillWidth: true + text: "Save a track with the arrow beside its heart. Downloads continue when you close the window." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.md; wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Action { text: "Queue & history"; icon: "download"; onClicked: Router.push("downloads") } + Action { + text: "Open download folder"; enabled: !!root.saved && Daemon.connected + onClicked: Daemon.call("open_download_folder").catch((e) => { root.error = e.message || String(e); }) + } + } + Text { + Layout.fillWidth: true + visible: root.loading || !Daemon.connected || root.error !== "" + text: !Daemon.connected ? "Reconnect to Ryotunes to change download preferences." + : root.error || "Loading download preferences…" + color: root.error ? Style.accent : Tokens.inkMuted + font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + Accessible.role: Accessible.StaticText + Accessible.name: text + } + Action { visible: !root.saved && !root.loading; enabled: Daemon.connected; text: "Try again"; onClicked: root.load() } + + ColumnLayout { + Layout.fillWidth: true + visible: !!root.saved + enabled: !root.saving && Daemon.connected + spacing: Style.sp(4) + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { text: "Download folder"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { + Layout.fillWidth: true + text: "An absolute path, or ~/ for your home folder. Missing folders are created when needed." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.ctlH + radius: Style.radius + color: Tokens.paperLift + border.width: folderField.activeFocus ? 2 : 1 + border.color: folderField.activeFocus ? Tokens.ink : Tokens.line + TextInput { + id: folderField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: root.pathInput + onTextChanged: root.pathInput = text + onAccepted: root.save() + Accessible.name: "Download folder" + } + } + Action { text: "Browse…"; enabled: !folderPicker.running; onClicked: folderPicker.running = true } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { text: "Audio format"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Flow { + Layout.fillWidth: true + spacing: Style.sp(2) + Repeater { + model: [{ key: "original", label: "Original" }, { key: "mp3", label: "MP3" }, { key: "opus", label: "Opus" }] + delegate: Choice { + required property var modelData + text: modelData.label + accessibleLabel: "Audio format: " + text + active: root.formatInput === modelData.key + onClicked: root.formatInput = modelData.key + } + } + } + Text { + Layout.fillWidth: true + text: root.formatInput === "original" ? "Best available source audio without lossy conversion. Fastest and lightest." + : root.formatInput === "mp3" ? "MP3 for broad compatibility. Conversion targets 320 kbps but cannot improve the source quality." + : "Opus for compact, efficient audio. Conversion targets 160 kbps; existing Opus audio is kept without re-encoding." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { text: "Simultaneous downloads"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + RowLayout { + spacing: Style.sp(2) + Repeater { + model: [1, 2, 3, 4] + delegate: Choice { + required property int modelData + text: String(modelData) + accessibleLabel: modelData + (modelData === 1 ? " download worker" : " download workers") + active: root.workersInput === modelData + onClicked: root.workersInput = modelData + } + } + } + Text { + Layout.fillWidth: true + text: "1 recommended for quieter listening. At most 4 workers; extra tracks wait in the queue. Lowering the limit lets running downloads finish." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + + Repeater { + model: [ + { key: "organize", title: "Artist subfolders", desc: "Keep each artist’s downloads together inside your download folder." }, + { key: "metadata", title: "Embed cover art & lyrics", desc: "Write track info, cover art and lyrics into the audio file, and save matching cover and lyrics files next to it. Artwork and lyrics not already in the download are fetched from free providers on a best-effort basis." } + ] + delegate: RowLayout { + id: option + required property var modelData + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Text { Layout.fillWidth: true; text: option.modelData.title; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { Layout.fillWidth: true; text: option.modelData.desc; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap } + } + Toggle { + id: toggle + checked: option.modelData.key === "organize" ? root.organizeInput : root.metadataInput + onToggled: (value) => { if (option.modelData.key === "organize") root.organizeInput = value; else root.metadataInput = value; } + activeFocusOnTab: enabled && visible + Accessible.role: Accessible.CheckBox + Accessible.name: option.modelData.title + Accessible.checkable: true + Accessible.checked: checked + Accessible.onToggleAction: toggle.toggled(!toggle.checked) + Keys.onSpacePressed: (event) => { toggle.toggled(!toggle.checked); event.accepted = true; } + Rectangle { + anchors.fill: parent; anchors.margins: -3; radius: height / 2 + color: "transparent"; border.width: toggle.activeFocus ? 2 : 0; border.color: Tokens.ink + } + } + } + } + } + RowLayout { + visible: !!root.saved + Layout.fillWidth: true + spacing: Style.sp(3) + Action { text: root.saving ? "Saving…" : "Save preferences"; primary: true; enabled: root.dirty && !root.saving && Daemon.connected; onClicked: root.save() } + Text { Layout.fillWidth: true; text: root.dirty ? "Unsaved changes" : "Saved · file preferences apply to new downloads"; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap } + } + Hairline { Layout.fillWidth: true; height: 1 } + Text { + Layout.fillWidth: true + text: "YouTube and SoundCloud tracks download from their source. Spotify tracks use a YouTube search match, which may be a different version. Local files are already on disk; live radio cannot be downloaded. Protected or unavailable tracks show an error. Cover and lyrics lookups send track details, including name and artist, to external providers. Requires yt-dlp and FFmpeg. Only download music you have permission to save." + color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Process { + id: folderPicker + command: ["zenity", "--file-selection", "--directory", "--title=Choose a download folder"] + stdout: StdioCollector { id: folderOutput } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0 && folderOutput.text.trim()) root.pathInput = folderOutput.text.trim(); + } + } +} diff --git a/client/pages/DownloadsPage.qml b/client/pages/DownloadsPage.qml new file mode 100644 index 0000000..93b1e55 --- /dev/null +++ b/client/pages/DownloadsPage.qml @@ -0,0 +1,691 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../lib/collections.js" as Collections +import "../" +import "../components" + +// The download manager surface (routed as "downloads", reached from the rail and the download +// button beside the like heart). It renders the shared Downloads mirror — the daemon owns every +// job, this page just reflects it and calls back through the singleton — split into a live queue +// (downloading / waiting, each cancellable with its progress) and a history (finished files with an +// open-folder action, and failures with their error and a retry). Nothing polls: Downloads updates +// from the subscribe snapshot and the downloads-changed event, and every row rebinds on its own. +// Clearing history removes terminal records only; saved files are never touched. +Item { + id: page + + readonly property var jobs: Downloads.jobs + + // Live jobs (downloading before queued), then terminal jobs newest-first for the history. + readonly property var queue: { + var out = []; + for (var i = 0; i < page.jobs.length; i++) { + var j = page.jobs[i]; + if (j && (j.status === "downloading" || j.status === "queued")) + out.push(j); + } + out.sort((a, b) => { + if (a.status !== b.status) + return a.status === "downloading" ? -1 : 1; + return (a.createdAt || 0) - (b.createdAt || 0); + }); + return out; + } + readonly property var history: { + var out = []; + for (var i = 0; i < page.jobs.length; i++) { + var j = page.jobs[i]; + if (j && (j.status === "completed" || j.status === "failed" || j.status === "cancelled")) + out.push(j); + } + out.sort((a, b) => ((b.finishedAt || b.createdAt || 0) - (a.finishedAt || a.createdAt || 0))); + return out; + } + + // Collapse jobs that share a collection label into one card; single tracks stay flat rows + // (Collections.groupJobs). The card sits where its first job sorted, so queue/history order + // is preserved. + readonly property var queueGroups: Collections.groupJobs(page.queue) + readonly property var historyGroups: Collections.groupJobs(page.history) + readonly property string folderPath: (Downloads.settings && Downloads.settings.path) ? Downloads.settings.path : "" + + Component.onCompleted: { + if (Daemon.connected) { + Downloads.refresh(); + Downloads.loadSettings(); + } + } + Connections { + target: Daemon + function onConnectedChanged(): void { + if (Daemon.connected) { + Downloads.refresh(); + Downloads.loadSettings(); + } + } + } + + // A focusable text button (Btn) with a keyboard focus ring, matching the settings page. + component Action: Btn { + id: action + activeFocusOnTab: enabled && visible + Keys.onSpacePressed: (event) => { action.clicked(); event.accepted = true; } + Keys.onReturnPressed: (event) => { action.clicked(); event.accepted = true; } + Accessible.onPressAction: action.clicked() + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Style.radius + color: "transparent" + border.width: action.activeFocus ? 2 : 0 + border.color: Tokens.ink + } + } + + // A focusable icon action for a row (cancel / open / retry). + component IconAction: IconButton { + id: iact + property string a11y: "" + activeFocusOnTab: enabled && visible + Accessible.role: Accessible.Button + Accessible.name: iact.a11y + Accessible.onPressAction: iact.clicked() + Keys.onSpacePressed: (event) => { iact.clicked(); event.accepted = true; } + Keys.onReturnPressed: (event) => { iact.clicked(); event.accepted = true; } + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Style.radius + color: "transparent" + border.width: iact.activeFocus ? 2 : 0 + border.color: Tokens.ink + } + } + + // One compact download row: artwork, title, source + artists, a status/progress line, and the + // action for its state. Handles every status so the queue and history reuse it. + component JobRow: Rectangle { + id: jr + property var job: null + readonly property string status: jr.job ? String(jr.job.status) : "" + readonly property int progress: (jr.job && jr.job.progress) ? Math.max(0, Math.min(100, jr.job.progress)) : 0 + readonly property string fileName: (jr.job && jr.job.filePath) ? String(jr.job.filePath).split("/").pop() : "" + + Layout.fillWidth: true + implicitHeight: Math.max(Style.sp(15), rowLay.implicitHeight + Style.sp(4)) + radius: Style.radius + color: rowHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + HoverHandler { id: rowHover } + + RowLayout { + id: rowLay + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + + Artwork { + Layout.alignment: Qt.AlignVCenter + url: (jr.job && jr.job.thumbnail) ? jr.job.thumbnail : "" + px: Style.sp(11) + } + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(0.5) + + Text { + Layout.fillWidth: true + text: (jr.job && jr.job.title) ? jr.job.title : "Untitled" + textFormat: Text.PlainText + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1.5) + Text { + text: (jr.job && jr.job.source) ? jr.job.source : "" + textFormat: Text.PlainText + visible: text !== "" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + elide: Text.ElideRight + Layout.maximumWidth: Style.sp(60) + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + visible: !!(jr.job && jr.job.artists) + text: (jr.job && jr.job.artists) ? jr.job.artists : "" + textFormat: Text.PlainText + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + // status / progress line + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(0.5) + spacing: Style.sp(2) + visible: jr.status !== "" + + // progress track (downloading only) + Rectangle { + visible: jr.status === "downloading" + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(60) + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.sp(1) + radius: height / 2 + color: Tokens.lineSoft + Rectangle { + height: parent.height + width: parent.width * jr.progress / 100 + radius: height / 2 + color: Style.accent + Behavior on width { + enabled: !Tokens.reduceMotion + NumberAnimation { duration: Style.motion.move; easing.type: Easing.OutCubic } + } + } + } + Text { + textFormat: Text.PlainText + text: { + switch (jr.status) { + case "downloading": return jr.progress + "%"; + case "queued": return "Waiting in queue"; + case "completed": return jr.fileName !== "" ? jr.fileName : "Saved"; + case "failed": return (jr.job && jr.job.error) ? jr.job.error : "Download failed"; + case "cancelled": return "Cancelled"; + default: return ""; + } + } + color: jr.status === "failed" ? Style.alert : Tokens.inkMuted + font.family: jr.status === "downloading" ? Style.fontMono : Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: jr.status === "failed" ? Text.WordWrap : Text.NoWrap + Layout.fillWidth: jr.status !== "downloading" + Layout.minimumWidth: 0 + } + Item { visible: jr.status === "downloading"; Layout.fillWidth: true } + } + + // Non-fatal metadata notes (missing cover/lyrics, or a tag that could not be + // written) for a completed download. The audio still saved, so these read as a + // muted aside rather than an error. + Text { + readonly property var warns: (jr.status === "completed" && jr.job && jr.job.warnings) ? jr.job.warnings : [] + Layout.fillWidth: true + Layout.topMargin: Style.sp(0.5) + visible: warns.length > 0 + textFormat: Text.PlainText + text: warns.join(" \u00b7 ") + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + Accessible.role: Accessible.StaticText + Accessible.name: text + } + } + + // trailing action for this state + IconAction { + Layout.alignment: Qt.AlignVCenter + visible: jr.status === "downloading" || jr.status === "queued" + icon: "close" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: "Cancel download" + onClicked: if (jr.job) Downloads.cancel(jr.job.id) + } + IconAction { + Layout.alignment: Qt.AlignVCenter + visible: jr.status === "completed" + icon: "folder" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: "Open file location" + onClicked: if (jr.job) Downloads.open(jr.job.id) + } + IconAction { + Layout.alignment: Qt.AlignVCenter + visible: jr.status === "failed" || jr.status === "cancelled" + icon: "loading" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: "Retry download" + onClicked: if (jr.job) Downloads.retry(jr.job.id) + } + } + } + + // One album/playlist batch collapsed into a single card: cover, kind, aggregate progress, + // and an expandable list of its per-track rows. A collection is what the user asked for, + // so the queue shows one card per collection instead of a hundred anonymous rows. + component CollectionCard: Rectangle { + id: cc + property var group: null + readonly property var jobs: cc.group ? cc.group.jobs : [] + readonly property string label: cc.group ? cc.group.label : "" + readonly property bool open: Downloads.openCollections[label] === true + readonly property var stats: Collections.stats(cc.jobs) + readonly property int doneCount: cc.stats.done + readonly property int waitingCount: cc.stats.waiting + readonly property int movingCount: cc.stats.moving + readonly property int failedCount: cc.stats.failed + readonly property int cancelledCount: cc.stats.cancelled + readonly property bool active: cc.movingCount + cc.waitingCount > 0 + readonly property int percent: cc.stats.percent + readonly property string summary: { + var parts = []; + if (movingCount > 0) + parts.push("Downloading " + doneCount + " of " + jobs.length); + else if (doneCount > 0) + parts.push(doneCount + " saved"); + if (waitingCount > 0) + parts.push(waitingCount + " waiting"); + if (failedCount > 0) + parts.push(failedCount + " failed"); + if (cancelledCount > 0) + parts.push(cancelledCount + " cancelled"); + return parts.join(" \u00b7 "); + } + // The first artwork the batch carried; album pages always have one. + readonly property string cover: { + for (var i = 0; i < jobs.length; i++) + if (jobs[i].thumbnail) + return jobs[i].thumbnail; + return ""; + } + + implicitHeight: body.implicitHeight + Style.sp(4) + radius: Style.radius + color: cardHover.hovered ? Tokens.tint5 : "transparent" + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + HoverHandler { id: cardHover } + + ColumnLayout { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.sp(2) + spacing: Style.sp(1) + + RowLayout { + id: header + Layout.fillWidth: true + spacing: Style.sp(3) + + Artwork { + Layout.alignment: Qt.AlignVCenter + url: cc.cover + px: Style.sp(11) + } + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: Style.sp(0.5) + Text { + text: (cc.group && cc.group.kind === "album") ? "Album" : "Playlist" + textFormat: Text.PlainText + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + text: cc.label + textFormat: Text.PlainText + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + // aggregate progress while anything is still moving + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + visible: cc.active + Rectangle { + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(60) + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.sp(1) + radius: height / 2 + color: Tokens.lineSoft + Rectangle { + height: parent.height + width: parent.width * cc.percent / 100 + radius: height / 2 + color: Style.accent + Behavior on width { + enabled: !Tokens.reduceMotion + NumberAnimation { duration: Style.motion.move; easing.type: Easing.OutCubic } + } + } + } + Text { + text: cc.percent + "%" + textFormat: Text.PlainText + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + } + } + Text { + Layout.fillWidth: true + text: cc.summary + textFormat: Text.PlainText + visible: text !== "" + color: (cc.failedCount + cc.cancelledCount) > 0 ? Style.alert : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + // Reveal the collection folder (any completed file sits in it). + IconAction { + Layout.alignment: Qt.AlignVCenter + visible: cc.doneCount > 0 + icon: "folder" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: "Open " + cc.label + " folder" + onClicked: { + for (var i = 0; i < cc.jobs.length; i++) + if (cc.jobs[i].status === "completed") { + Downloads.open(cc.jobs[i].id); + return; + } + } + } + // Cancel every unfinished track of this collection. + IconAction { + Layout.alignment: Qt.AlignVCenter + visible: cc.active + icon: "close" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: "Cancel " + cc.label + " downloads" + onClicked: { + for (var i = 0; i < cc.jobs.length; i++) + if (cc.jobs[i].status === "downloading" || cc.jobs[i].status === "queued") + Downloads.cancel(cc.jobs[i].id); + } + } + IconAction { + Layout.alignment: Qt.AlignVCenter + icon: cc.open ? "chevron-down" : "chevron-right" + iconSize: Style.fs.md + diameter: Style.sp(9) + a11y: (cc.open ? "Collapse " : "Expand ") + cc.label + onClicked: Downloads.toggleCollection(cc.label) + } + } + + // Expanded: the per-track rows, indented under their collection. + ColumnLayout { + id: rows + Layout.fillWidth: true + Layout.leftMargin: Style.sp(4) + visible: cc.open + spacing: 0 + Repeater { + model: cc.jobs + delegate: JobRow { + required property var modelData + job: modelData + } + } + } + } + } + + // One entry of the grouped queue/history list: a collection card when the batch shares a + // label, a flat JobRow for a single track. + component GroupRow: Loader { + required property var entry + Layout.fillWidth: true + sourceComponent: entry && entry.group ? collectionCard : jobRow + readonly property var group: entry ? entry.group : null + readonly property var job: entry ? entry.job : null + Component { + id: collectionCard + CollectionCard { group: parent.group } + } + Component { + id: jobRow + JobRow { job: parent.job } + } + } + Flickable { + id: scroll + anchors.fill: parent + contentWidth: width + contentHeight: content.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + bottomMargin: Style.sp(20) + + ColumnLayout { + id: content + width: scroll.width + spacing: Style.sp(4) + + // --- header ------------------------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: "Downloads" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: page.folderPath !== "" ? "Saving to " + page.folderPath + : "Save a track with the download arrow beside its heart in the player." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Action { + text: "Open folder" + icon: "folder" + enabled: Daemon.connected + onClicked: Downloads.openFolder() + } + Action { + text: "Clear history" + icon: "trash" + enabled: Daemon.connected && page.history.length > 0 + onClicked: Downloads.clearHistory() + } + Item { Layout.fillWidth: true } + Action { + text: "Settings" + icon: "settings" + onClicked: Router.push("settings", { section: "downloads" }) + } + } + + // disconnected banner (state may be stale until reconnect) + Rectangle { + Layout.fillWidth: true + visible: !Daemon.connected + implicitHeight: dcRow.implicitHeight + Style.sp(4) + radius: Style.radius + color: Tokens.tint5 + border.width: 1 + border.color: Tokens.line + RowLayout { + id: dcRow + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(2) + Icon { name: "alert"; size: Style.fs.md; color: Style.alert } + Text { + Layout.fillWidth: true + text: "Disconnected from Ryotunes. This list will update when it reconnects. Interrupted downloads can be retried." + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + } + + Text { + Layout.fillWidth: true + visible: Daemon.connected && (Downloads.error !== "" || (!Downloads.loaded && Downloads.loading)) + text: Downloads.error || "Loading downloads…" + color: Downloads.error ? Style.alert : Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Action { + visible: Daemon.connected && Downloads.error !== "" + enabled: !Downloads.loading + text: "Try again" + onClicked: Downloads.refresh() + } + + Hairline { Layout.fillWidth: true; height: 1 } + + // --- big empty state (no jobs at all) ----------------------------------------- + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(10) + visible: page.jobs.length === 0 && Downloads.loaded && Downloads.error === "" + spacing: Style.sp(2) + Icon { + Layout.alignment: Qt.AlignHCenter + name: "download" + size: Style.fs.hero + color: Tokens.inkFaint + } + Text { + Layout.alignment: Qt.AlignHCenter + text: Daemon.connected ? "No downloads yet" : "Not connected" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.Medium + } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Style.sp(120) + horizontalAlignment: Text.AlignHCenter + text: Daemon.connected + ? "Save a track with the download arrow beside its heart in the player. Downloads keep running when you close the window." + : "Your download history will update when Ryotunes reconnects." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + wrapMode: Text.WordWrap + } + } + + // --- queue -------------------------------------------------------------------- + ColumnLayout { + Layout.fillWidth: true + visible: page.jobs.length > 0 + spacing: Style.sp(2) + + SectionHeading { + Layout.fillWidth: true + title: page.queue.length > 0 ? "Queue \u00b7 " + page.queue.length : "Queue" + mark: "待" + } + Text { + Layout.fillWidth: true + visible: page.queue.length === 0 + text: "Nothing downloading right now." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Repeater { + model: page.queueGroups + delegate: GroupRow { + required property var modelData + entry: modelData + } + } + } + + // --- history ------------------------------------------------------------------ + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + visible: page.jobs.length > 0 + spacing: Style.sp(2) + + SectionHeading { + Layout.fillWidth: true + title: page.history.length > 0 ? "History \u00b7 " + page.history.length : "History" + mark: "録" + } + Text { + Layout.fillWidth: true + text: "Latest 200 downloads. Clearing history keeps your music files." + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + visible: page.history.length === 0 + text: "Finished downloads will appear here." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Repeater { + model: page.historyGroups + delegate: GroupRow { + required property var modelData + entry: modelData + } + } + } + } + } +} diff --git a/client/pages/HomePage.qml b/client/pages/HomePage.qml new file mode 100644 index 0000000..411f701 --- /dev/null +++ b/client/pages/HomePage.qml @@ -0,0 +1,719 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../chrome" +import "../lib/browse.js" as Browse +import "../lib/ids.js" as Ids +import "../lib/recommendations.js" as Recommendations + +// Home (spec section 5), ported from ui/src/routes/+page.svelte and reset onto the visual system. +// One vertical reused ListView of the feed's shelves; the header carries the greeting hero (with +// the listening deck at its right on a wide page), the mood-chip rail, the Pinned tile grid and the +// personal shelves — Listen again, Familiar artists and Forgotten favourites. The footer carries the +// loading skeletons, the empty / error states and the progressive get_home_more pagination +// (it fires when the tail comes within 400 px of the viewport bottom). The page fills the content +// rect the frame already pads (32 sides / 24 top / 32 bottom); it never insets itself. +Item { + id: page + + property var home: null + property var chips: [] + property var forgotten: null + property var listenAgain: null + // The hero's search field, for the rig's `suggest` hook. + property var heroSearch: null + property string selected: "" + property bool loading: true + property string errorMsg: "" + property bool loadingMore: false + property bool moreError: false + ListModel { id: blocks } + property int requestId: 0 + property var recommendations: ({ items: [], explanation: "" }) + + // Home is the merged feed: YouTube Music's own shelves (always available, signed in or + // not) plus the shelves of every signed-in provider. There is no sign-in gate here — + // Library and Search still gate on the selected catalogue. + + // Personal shelves, live off the shared store. + readonly property var recents: Personal.recent(100).filter((item) => page.providerFor(item.id) === Playback.provider).slice(0, 6) + readonly property var forgottenList: page.forgottenSongs() + + // Familiar artists: the most-played artists (topArtistIds) resolved to round cards. Loaded once. + property var famIds: Personal.topArtistIds(6) + property var famArtists: [] + property bool famLoaded: false + onFamIdsChanged: page.loadFamiliar() + + Component.onCompleted: { page.load(""); page.loadFamiliar(); } + + // A provider switch swaps the whole catalogue: reload the feed (or fall to the sign-in card). + Connections { + target: Playback + function onProviderChanged(): void { page.load(page.selected); } + } + + Connections { + target: Personal + function onBlobChanged(): void { Qt.callLater(page.refreshRecommendations); } + } + + function providerFor(id) { + return Ids.providerOf(id); + } + function refreshRecommendations() { + page.recommendations = Recommendations.build( + page.home && page.home.sections ? page.home.sections : [], + Personal.blob, Playback.provider, Date.now()); + } + + function greeting() { + var h = new Date().getHours(); + return h < 5 ? "Still up" : h < 12 ? "Good morning" : h < 17 ? "Good afternoon" : h < 22 ? "Good evening" : "Good night"; + } + + function isForgotten(s) { + if (!/forgotten/i.test(s.title)) + return false; + for (var i = 0; i < s.items.length; i++) + if (s.items[i].kind === "song") + return true; + return false; + } + + function appendSections(secs) { + // Keep the model alive across continuation responses. Replacing a JS-array model + // resets ListView to the header, even after the user has started scrolling. + for (var i = 0; i < secs.length; i++) { + if (page.isForgotten(secs[i])) { + if (!page.forgotten) + page.forgotten = secs[i]; + } else if (!page.listenAgain && /listen again/i.test(secs[i].title)) { + page.listenAgain = secs[i]; + } else { + blocks.append({ sectionData: secs[i] }); + } + } + page.refreshRecommendations(); + } + + function forgottenSongs() { + if (!page.forgotten) + return []; + return page.forgotten.items.filter((i) => i.kind === "song").slice(0, 15); + } + + function loadFamiliar() { + if (page.famLoaded || page.famIds.length < 3) + return; + page.famLoaded = true; + Promise.all(page.famIds.map((id) => Daemon.call("get_artist", { id: id }).catch(() => null))) + .then((pages) => { page.famArtists = pages.filter((p) => !!p); }); + } + + function load(params) { + page.selected = params; + var request = ++page.requestId; + page.loadingMore = false; + list.cancelFlick(); + list.stickTop = true; + page.moreError = false; + blocks.clear(); + page.forgotten = null; + page.listenAgain = null; + page.loading = true; + page.errorMsg = ""; + Daemon.call("get_home", { params: params ? params : null }) + .then((h) => { + if (page.requestId !== request) + return; + page.home = h; + if (h.chips && h.chips.length) + page.chips = h.chips.filter((c) => c.title !== "Podcasts"); + page.appendSections(h.sections || []); + page.loading = false; + }) + .catch((e) => { + if (page.requestId !== request) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + function loadMore() { + if (!page.home || !page.home.continuation || page.loadingMore || page.moreError) + return; + page.loadingMore = true; + var token = page.home.continuation; + var request = page.requestId; + Daemon.call("get_home_more", { token: token }) + .then((more) => { + if (page.requestId !== request || !page.home || page.home.continuation !== token) + return; + page.home = { + chips: page.home.chips, + sections: page.home.sections.concat(more.sections), + continuation: more.sections.length ? more.continuation : undefined + }; + page.appendSections(more.sections); + page.loadingMore = false; + }) + .catch(() => { + if (page.requestId !== request) + return; + page.moreError = true; + page.loadingMore = false; + Playback.toast("Could not load more", "error"); + }); + } + + function maybeLoadMore() { + if (page.loading || !page.home || !page.home.continuation || page.loadingMore || page.moreError) + return; + if (list.contentHeight <= 0) + return; + if (!list.stickTop && list.contentY - list.originY + list.height > list.contentHeight - 400) + page.loadMore(); + } + + HomeFeed { + id: list + anchors.fill: parent + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + model: blocks + spacing: Style.sp(10) + + // HomeFeed releases its top pin without restoring a stale pre-layout offset. + onContentYChanged: page.maybeLoadMore() + onContentHeightChanged: page.maybeLoadMore() + + delegate: Item { + required property var sectionData + width: list.width + implicitHeight: shelf.implicitHeight + Shelf { + id: shelf + width: parent.width + section: parent.sectionData + mark: Style.decorRich ? "章" : "" + } + } + + header: Item { + // The hero's search panel hangs below its own row: keep the header above the delegates + // (the feed shelves) so the panel is never painted under them. + z: 2 + width: list.width + // The list's spacing runs between delegates only; the header carries its own section + // gap so the first shelf never rides up against the last header block. + implicitHeight: headerCol.implicitHeight + Style.sp(10) + + ColumnLayout { + id: headerCol + width: parent.width + spacing: Style.sp(10) + + // hero: the greeting, search and key hints on the left (inset 48 px); the Now + // Playing card on the right when the page is wide enough that it never crowds the + // greeting column (>= 1180 px) and something is playing. + RowLayout { + id: hero + z: 2 + Layout.fillWidth: true + readonly property bool wide: width >= Style.sp(295) + spacing: 0 + + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(120) + Layout.minimumWidth: Style.sp(70) + Layout.leftMargin: Style.sp(4) + Layout.alignment: Qt.AlignTop + spacing: Style.sp(2) + + // — 力 HOME / LISTEN ··· 01 + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { Layout.preferredWidth: Style.sp(4); Layout.preferredHeight: 1; Layout.alignment: Qt.AlignVCenter; color: Tokens.ink } + Text { text: "力"; color: Tokens.ink; font.family: Tokens.jp; font.pixelSize: Style.fs.sm } + Text { + text: "HOME / LISTEN" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; Layout.alignment: Qt.AlignVCenter; color: Tokens.lineSoft } + Text { + text: "01" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + Text { + Layout.fillWidth: true + text: page.greeting() + ((Playback.auth && Playback.auth.signedIn && Playback.auth.name) ? (", " + Playback.auth.name) : "") + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.hero + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: Playback.provider === "soundcloud" && !(Playback.soundcloud && Playback.soundcloud.signedIn) + ? "SoundCloud \u00b7 listening as a guest" + : "Pick up where you left off, or find the next thing worth hearing." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + + // the search field, with a Ctrl+K kbd chip inset at its right edge + Item { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + implicitHeight: heroSearch.implicitHeight + z: 40 + SearchSuggest { + id: heroSearch + Component.onCompleted: page.heroSearch = heroSearch + width: parent.width + placeholder: "Search tracks, albums, artists…" + onSubmitted: if (value.trim() !== "") Router.push("search", { q: value.trim() }) + onPicked: if (value.trim() !== "") Router.push("search", { q: value.trim() }) + z: 40 + } + Rectangle { + id: kbd + z: 50 + visible: heroSearch.value === "" + anchors.right: parent.right + anchors.rightMargin: Style.sp(2) + anchors.top: parent.top + anchors.topMargin: (Style.sp(11) - height) / 2 + implicitHeight: Style.sp(6) + implicitWidth: kbdText.implicitWidth + Style.sp(3) + radius: Style.radius + color: Tokens.tint5 + border.width: 1 + border.color: Tokens.line + Text { + id: kbdText + anchors.centerIn: parent + text: "Ctrl+K" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + + // key hints + RowLayout { + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Text { text: "Ctrl+K"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Text { text: "command search"; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs } + Rectangle { Layout.preferredWidth: Style.sp(4); Layout.preferredHeight: 1; Layout.alignment: Qt.AlignVCenter; color: Tokens.lineSoft } + Text { text: "Space"; color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.micro; font.letterSpacing: Style.trackMicro } + Text { text: "play / pause"; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs } + } + } + + Item { Layout.fillWidth: true; Layout.minimumWidth: hero.wide ? Style.sp(10) : 0 } + NowPlayingCard { + visible: hero.wide && !!Playback.now + Layout.preferredWidth: Style.sp(138) + Layout.maximumWidth: Style.sp(140) + Layout.alignment: Qt.AlignTop + onOpenQueue: Playback.nowPlayingRequested("queue") + } + } + + // The restored listening session must remain reachable on a narrow Home too. + RowLayout { + Layout.fillWidth: true + visible: !!Playback.now + spacing: Style.sp(3) + Artwork { + visible: !hero.wide + url: Playback.now && Playback.now.thumbnail ? Playback.now.thumbnail : "" + px: Style.sp(14) + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: Playback.paused ? "Continue listening" : "Your listening session" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + } + Text { + Layout.fillWidth: true + text: Playback.now ? Playback.now.title + " — " + Playback.now.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + Btn { + text: Playback.paused ? "Resume" : "Pause" + icon: Playback.paused ? "play" : "pause" + primary: Playback.paused + onClicked: Playback.togglePause().catch((e) => Playback.toast(String(e), "error")) + } + IconButton { + icon: "queue" + tip: "Open your queue" + onClicked: Playback.nowPlayingRequested("queue") + } + } + + HomePersonal { + Layout.fillWidth: true + visible: page.selected === "" + recents: page.recents + } + + // chip rail — a horizontal Flickable whose right edge fades into the paper (never a + // hard cut on the last chip) via a gradient overlay rather than an OpacityMask. + Item { + Layout.fillWidth: true + implicitHeight: chipRow.implicitHeight + visible: page.chips.length > 0 + Flickable { + id: chipsFlick + anchors.fill: parent + contentWidth: chipRow.implicitWidth + contentHeight: chipRow.implicitHeight + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true + Row { + id: chipRow + spacing: Style.sp(2) + Chip { + text: "All" + active: page.selected === "" + onClicked: page.load("") + } + Repeater { + model: page.chips + delegate: Chip { + required property var modelData + text: modelData.title + active: page.selected === modelData.params + onClicked: page.load(page.selected === modelData.params ? "" : modelData.params) + } + } + } + } + Rectangle { + visible: chipsFlick.contentWidth > chipsFlick.width + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Style.sp(12) + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Tokens.paper } + } + } + } + + // shortcuts (pinned, unfiltered only) — bordered 56 px cards, then a dashed + // "Add shortcut" card of the same height + ColumnLayout { + Layout.fillWidth: true + visible: page.selected === "" + spacing: Style.sp(4) + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Icon { Layout.alignment: Qt.AlignVCenter; name: "dashboard"; size: Style.fs.md; color: Tokens.inkMuted } + SectionHeading { Layout.fillWidth: true; title: "// Shortcuts"; mark: Style.decorRich ? "選" : "" } + } + Flow { + id: pinFlow + Layout.fillWidth: true + spacing: Style.sp(4) + + Repeater { + model: Personal.picks + delegate: Rectangle { + id: pin + required property var modelData + readonly property bool round: pin.modelData && pin.modelData.kind === "artist" + width: Style.sp(80) + height: Style.sp(14) + radius: Style.radiusCard + color: pinHover.hovered ? Tokens.tint5 : "transparent" + border.width: 1 + border.color: pinHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on color { ColorAnimation { duration: Style.motion.snap } } + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Artwork { + Layout.alignment: Qt.AlignVCenter + url: pin.modelData && pin.modelData.thumbnail ? pin.modelData.thumbnail : "" + px: Style.sp(10) + round: pin.round + placeholderIcon: pin.round ? "user" + : (pin.modelData && Ids.isOnRepeatId(pin.modelData.id)) ? "on-repeat" : "music" + } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: pin.modelData ? pin.modelData.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: (pin.modelData && pin.modelData.subtitle) ? pin.modelData.subtitle + : (pin.modelData ? pin.modelData.kind : "") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + textFormat: Text.PlainText + } + } + IconButton { + visible: pinHover.hovered + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(7) + tip: "Remove" + onClicked: Personal.removePick(pin.modelData.id) + } + } + HoverHandler { id: pinHover } + TapHandler { + onTapped: { + if (pin.modelData.kind === "song") + Playback.play(Browse.asSong(pin.modelData)); + else + Router.push(pin.modelData.kind, { id: pin.modelData.id, title: pin.modelData.title }); + } + acceptedButtons: Qt.LeftButton + } + } + } + + // dashed "Add shortcut" card + Item { + id: addTile + width: Style.sp(80) + height: Style.sp(14) + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + strokeColor: addHover.hovered ? Tokens.lineStrong : Tokens.line + strokeWidth: 1 + fillColor: addHover.hovered ? Tokens.tint5 : "transparent" + strokeStyle: ShapePath.DashLine + dashPattern: [4, 4] + joinStyle: ShapePath.RoundJoin + PathSvg { + path: { + var o = 0.5; + var w = addTile.width - o * 2; + var h = addTile.height - o * 2; + var r = Style.radiusCard; + return "M " + (o + r) + " " + o + + " H " + (o + w - r) + + " A " + r + " " + r + " 0 0 1 " + (o + w) + " " + (o + r) + + " V " + (o + h - r) + + " A " + r + " " + r + " 0 0 1 " + (o + w - r) + " " + (o + h) + + " H " + (o + r) + + " A " + r + " " + r + " 0 0 1 " + o + " " + (o + h - r) + + " V " + (o + r) + + " A " + r + " " + r + " 0 0 1 " + (o + r) + " " + o + + " Z"; + } + } + } + } + RowLayout { + anchors.centerIn: parent + spacing: Style.sp(2) + Icon { name: "add"; size: Style.fs.md; color: Tokens.inkMuted } + ColumnLayout { + spacing: Style.sp(0.5) + Text { text: "Add shortcut"; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { text: "Recent, library or YouTube link"; color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs } + } + } + HoverHandler { id: addHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: shortcutPicker.open() } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: page.selected === "" && page.recommendations.items.length > 0 + spacing: Style.sp(2) + Shelf { + Layout.fillWidth: true + title: "Picked for you" + items: page.recommendations.items + } + Text { + Layout.fillWidth: true + text: page.recommendations.explanation + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + + HomePersonal { + Layout.fillWidth: true + // The recents / familiar artists are YouTube Music history. The merged + // home always carries the YouTube spine, so they always belong; a mood + // chip filter narrows the YouTube feed alone, so they step aside then. + visible: page.selected === "" + artists: page.famArtists + listenAgain: page.listenAgain + } + + // forgotten favourites (from the feed) + Shelf { + Layout.fillWidth: true + visible: !!page.forgotten && page.forgottenList.length > 0 + title: page.forgotten ? page.forgotten.title : "Forgotten favourites" + mark: Style.decorRich ? "忘" : "" + items: page.forgottenList + } + } + } + + footer: Item { + width: list.width + implicitHeight: footerCol.implicitHeight + Style.sp(20) + + ColumnLayout { + id: footerCol + width: parent.width + spacing: Style.sp(9) + + // loading skeletons + Repeater { + model: page.loading ? 3 : 0 + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Skeleton { Layout.preferredWidth: Style.sp(40); Layout.preferredHeight: Style.sp(4) } + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + Repeater { + model: 6 + delegate: Skeleton { + required property int index + Layout.preferredWidth: Style.cardW + Layout.preferredHeight: Style.cardW + corner: Style.radiusCard + } + } + } + } + } + + // error + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + visible: !page.loading && page.errorMsg !== "" + spacing: Style.sp(2) + Text { + Layout.alignment: Qt.AlignHCenter + text: page.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + Chip { + Layout.alignment: Qt.AlignHCenter + text: "Try again" + onClicked: page.load(page.selected) + } + } + + // empty + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Style.sp(16) + visible: !page.loading && page.errorMsg === "" && blocks.count === 0 && !page.forgotten + spacing: Style.sp(3) + Icon { Layout.alignment: Qt.AlignHCenter; name: "music"; size: Style.fs.hero; color: Tokens.inkFaint } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Style.sp(90) + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: (Playback.auth && Playback.auth.signedIn) + ? "Your home feed came back empty this time." + : "Sign in and home fills up with mixes and playlists built from what you listen to." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + Chip { + Layout.alignment: Qt.AlignHCenter + text: (Playback.auth && Playback.auth.signedIn) ? "Try again" : "Sign in with Google" + active: !(Playback.auth && Playback.auth.signedIn) + onClicked: (Playback.auth && Playback.auth.signedIn) + ? page.load(page.selected) + : Daemon.call("sign_in").catch(() => {}) + } + } + + // load-more affordance + Chip { + Layout.alignment: Qt.AlignHCenter + visible: !page.loading && !!(page.home && page.home.continuation) && page.moreError + text: page.loadingMore ? "Loading…" : "Try again" + onClicked: { page.moreError = false; page.loadMore(); } + } + } + } + } + + ShortcutPicker { + id: shortcutPicker + anchors.fill: parent + z: 100 + } +} diff --git a/client/pages/LibraryPage.qml b/client/pages/LibraryPage.qml new file mode 100644 index 0000000..c9967d2 --- /dev/null +++ b/client/pages/LibraryPage.qml @@ -0,0 +1,332 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell.Io +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The library page, ported from ui/src/routes/library/+page.svelte. Account collections load in +// parallel on open (get_library / _albums / _artists) and fill the card tabs (Albums / Artists / +// Playlists); Songs, Local and Insights are the ported LibrarySongs / LocalMusic / +// ListeningInsights, loaded lazily the first time their tab is opened so an unopened tab costs +// nothing. A PageHero-less title row carries the count and the toolbar (create a playlist, import +// one from a file over the socket with a path); a segmented control switches the body. +Item { + id: page + + property var playlists: [] + property var albums: [] + property var artists: [] + property bool loading: true + property string errorMsg: "" + property string tab: "songs" + property var opened: [] + property bool creating: false + property string newName: "" + + + // Spotify is selected but not signed in: show the sign-in empty state instead of loading a + // library from the daemon. + readonly property bool spotifyGate: Playback.provider === "spotify" && !(Playback.spotify && Playback.spotify.signedIn) + // The sidebar deep-links a tab (Router.push("library", { tab })). Honour it on every navigation, + // not just the first mount, so clicking a Library sub-item while already on the page switches + // tabs. A plain library nav (no tab) leaves the current tab alone. Tab clicks are local state + // (no route push), so they never round-trip through here. + readonly property var params: Router.current ? Router.current.params : ({}) + onParamsChanged: { + var t = (page.params && page.params.tab) ? page.params.tab : ""; + if (t && t !== page.tab) + page.selectTab(t); + } + + readonly property var tabs: [ + { k: "songs", l: "Songs" }, + { k: "albums", l: "Albums" }, + { k: "artists", l: "Artists" }, + { k: "playlists", l: "Playlists" }, + { k: "local", l: "Local" }, + { k: "insights", l: "Insights" } + ] + readonly property bool gridTab: page.tab === "albums" || page.tab === "artists" || page.tab === "playlists" + readonly property var gridModel: { + if (page.tab === "playlists") return page.playlists; + if (page.tab === "albums") return page.albums; + if (page.tab === "artists") return page.artists; + return []; + } + + // The count meta beside the title reflects the active tab's collection size where the page owns + // it (Songs / Local / Insights load their own rows, so they carry no page-level count). + function tabMeta() { + if (page.tab === "playlists") return page.playlists.length + (page.playlists.length === 1 ? " playlist" : " playlists"); + if (page.tab === "albums") return page.albums.length + (page.albums.length === 1 ? " album" : " albums"); + if (page.tab === "artists") return page.artists.length + (page.artists.length === 1 ? " artist" : " artists"); + return ""; + } + + Component.onCompleted: { + var t = (Router.current && Router.current.params && Router.current.params.tab) + ? Router.current.params.tab : "songs"; + page.selectTab(t); + page.load(); + } + + // A provider switch swaps the whole library: reload (or fall to the sign-in card). + Connections { + target: Playback + function onProviderChanged(): void { page.load(); } + } + + // A device-playlist mutation can change a playlist's auto cover: silently re-read the playlist + // collection (only playlists carry auto collages; albums/artists have provider art) so the cards + // follow without the loading overlay flashing over the grid. + Connections { + target: Daemon + function onEvent(name: string, data: var): void { + if (name === "library-changed") + page.refreshPlaylists(); + } + } + + function selectTab(k) { + page.tab = k; + if (page.opened.indexOf(k) < 0) + page.opened = page.opened.concat([k]); + } + function isOpened(k) { return page.opened.indexOf(k) >= 0; } + + function load() { + // Gated: no daemon call, the sign-in card carries the page. + if (page.spotifyGate) { + page.playlists = []; + page.albums = []; + page.artists = []; + page.errorMsg = ""; + page.loading = false; + return; + } + page.loading = true; + page.errorMsg = ""; + Promise.all([ + Daemon.call("get_library").catch(() => []), + Daemon.call("get_library_albums").catch(() => []), + Daemon.call("get_library_artists").catch(() => []) + ]).then((res) => { + page.playlists = res[0] || []; + page.albums = res[1] || []; + page.artists = res[2] || []; + page.loading = false; + }).catch((e) => { + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + function refreshPlaylists() { + if (page.spotifyGate) + return; + Daemon.call("get_library") + .then((r) => { page.playlists = r || []; }) + .catch(() => {}); + } + + function createPlaylist() { + var title = page.newName.trim(); + if (!title || page.creating) + return; + page.creating = true; + Daemon.call("create_playlist", { title: title }) + .then(() => { page.creating = false; page.newName = ""; newDialog.visible = false; page.load(); Playback.toast("Playlist created", "success"); }) + .catch((e) => { page.creating = false; Playback.toast((e && e.message) ? e.message : "Could not create", "error"); }); + } + + // import: zenity file picker -> import_playlist_file(path) -> create_playlist + add each + Process { + id: importPicker + command: ["zenity", "--file-selection", "--title=Import a playlist file"] + stdout: StdioCollector { + id: importOut + onStreamFinished: { var p = importOut.text.trim(); if (p) page.doImport(p); } + } + } + function doImport(path) { + Daemon.call("import_playlist_file", { path: path }) + .then((transfer) => { + if (!transfer || !transfer.items || !transfer.items.length) + return; + return Daemon.call("create_playlist", { title: transfer.title }).then((id) => { + var chain = Promise.resolve(); + for (var i = 0; i < transfer.items.length; i++) { + (function (song) { + chain = chain.then(() => Daemon.call("add_to_playlist", { playlistId: id, videoId: song.video_id }).catch(() => {})); + })(transfer.items[i]); + } + return chain.then(() => { page.load(); Playback.toast("Imported " + transfer.title, "success"); }); + }); + }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not import", "error")); + } + + ColumnLayout { + anchors.fill: parent + visible: !page.spotifyGate + spacing: Style.sp(4) + + // title row: display title + active-tab count, then the collection toolbar + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + spacing: Style.sp(1) + Text { + text: "Library" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + } + Text { + visible: text !== "" + text: page.tabMeta() + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + Item { Layout.fillWidth: true } + Pill { label: "Import"; icon: "add"; onClicked: importPicker.running = true } + Pill { label: "New playlist"; icon: "playlist"; primary: true; onClicked: { page.newName = ""; newDialog.visible = true; } } + } + + // segmented control + Flickable { + Layout.fillWidth: true + implicitHeight: tabRow.implicitHeight + contentWidth: tabRow.implicitWidth + contentHeight: tabRow.implicitHeight + flickableDirection: Flickable.HorizontalFlick + clip: true + Row { + id: tabRow + spacing: Style.sp(2) + Repeater { + model: page.tabs + delegate: Chip { + required property var modelData + text: modelData.l + active: page.tab === modelData.k + onClicked: page.selectTab(modelData.k) + } + } + } + } + + Hairline { Layout.fillWidth: true } + + // body: card grids for the account collections, lazy loaders for the ported sub-surfaces + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + CardGrid { + anchors.fill: parent + visible: page.gridTab + loading: page.loading + model: page.gridModel + pad: 0 + emptyText: (Playback.auth && Playback.auth.signedIn) ? "Nothing saved yet." : "Sign in to see your library." + } + + Loader { + anchors.fill: parent + active: page.isOpened("songs") && !page.spotifyGate + visible: page.tab === "songs" + sourceComponent: LibrarySongs {} + } + Loader { + anchors.fill: parent + active: page.isOpened("local") && !page.spotifyGate + visible: page.tab === "local" + sourceComponent: LocalMusic {} + } + Loader { + anchors.fill: parent + active: page.isOpened("insights") && !page.spotifyGate + visible: page.tab === "insights" + sourceComponent: ListeningInsights {} + } + } + } + + // The Spotify sign-in empty state: shown when Spotify is selected but not signed in, in place of + // the library. The button starts the OAuth flow; nothing here touches the daemon. + SpotifyGate { + anchors.centerIn: parent + visible: page.spotifyGate + } + + // new-playlist dialog + Item { + id: newDialog + anchors.fill: parent + visible: false + z: 210 + MouseArea { anchors.fill: parent; onClicked: newDialog.visible = false } + Rectangle { anchors.fill: parent; color: "#000000"; opacity: 0.5 } + Rectangle { + anchors.centerIn: parent + width: Style.sp(90) + implicitHeight: ndCol.implicitHeight + Style.sp(8) + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineStrong + MouseArea { anchors.fill: parent } + ColumnLayout { + id: ndCol + anchors.fill: parent + anchors.margins: Style.sp(4) + spacing: Style.sp(3) + Text { text: "New playlist"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.lg; font.weight: Font.DemiBold } + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paper + border.width: 1 + border.color: ndField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: ndField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: page.newName + onTextChanged: page.newName = text + onAccepted: page.createPlaylist() + Component.onCompleted: if (newDialog.visible) ndField.forceActiveFocus() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: ndField.text.length === 0 + text: "Playlist name" + color: Tokens.inkFaint + font: ndField.font + } + } + } + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Style.sp(2) + Pill { label: "Cancel"; onClicked: newDialog.visible = false } + Pill { label: "Create"; primary: true; enabled: !page.creating && page.newName.trim().length > 0; onClicked: page.createPlaylist() } + } + } + } + onVisibleChanged: if (visible) ndField.forceActiveFocus() + } +} diff --git a/client/pages/ListPage.qml b/client/pages/ListPage.qml new file mode 100644 index 0000000..dd06e2a --- /dev/null +++ b/client/pages/ListPage.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// A browse grid, ported from ui/src/routes/list/+page.svelte. One get_browse_grid call for the +// {id, params} the caller routed with (a shelf's "See all"); the result is however many cards +// YouTube sends, drawn in a reused GridView so a long grid never mounts more than a couple of +// screenfuls. A browse list has no cover or play-all, so it opens with a plain page-title header +// (tracked eyebrow + Fraunces title) rather than the media pages' PageHero, then the card grid. +Item { + id: page + + readonly property var params: Router.current ? Router.current.params : ({}) + readonly property string browseId: page.params && page.params.id ? page.params.id : "" + readonly property string browseParams: page.params && page.params.params ? page.params.params : "" + readonly property string title: page.params && page.params.title ? page.params.title : "More" + + property var items: [] + property bool loading: true + property string errorMsg: "" + + onParamsChanged: page.load() + Component.onCompleted: page.load() + + function load() { + if (!page.browseId) + return; + page.loading = true; + page.errorMsg = ""; + page.items = []; + var reqId = page.browseId; + Daemon.call("get_browse_grid", { id: page.browseId, params: page.browseParams ? page.browseParams : null }) + .then((items) => { + if (page.browseId !== reqId) + return; + page.items = items || []; + page.loading = false; + }) + .catch((e) => { + if (page.browseId !== reqId) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + GridView { + id: grid + anchors.fill: parent + topMargin: 0 + bottomMargin: Style.sp(20) + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + readonly property int cols: Math.max(2, Math.floor(width / (Style.cardW + Style.sp(4)))) + cellWidth: Math.floor(width / grid.cols) + cellHeight: grid.cellWidth + Style.sp(16) + model: page.loading || page.errorMsg ? [] : page.items + + header: Item { + width: grid.width + implicitHeight: headCol.implicitHeight + Style.sp(6) + ColumnLayout { + id: headCol + width: parent.width + spacing: Style.sp(1) + Text { + text: "BROWSE" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + text: page.title + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + elide: Text.ElideRight + } + } + } + + delegate: Item { + required property var modelData + width: grid.cellWidth + height: grid.cellHeight + MediaCard { + anchors.horizontalCenter: parent.horizontalCenter + y: 0 + item: parent.modelData + cardWidth: grid.cellWidth - Style.sp(4) + } + } + } + + // loading / error / empty + ColumnLayout { + anchors.centerIn: parent + spacing: Style.sp(2) + visible: page.loading || page.errorMsg !== "" || (!page.items.length) + Text { + Layout.alignment: Qt.AlignHCenter + text: page.loading ? "Loading…" : page.errorMsg !== "" ? page.errorMsg : "Nothing here." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + Rectangle { + Layout.alignment: Qt.AlignHCenter + visible: page.errorMsg !== "" + implicitWidth: Style.sp(20) + implicitHeight: Style.ctlH + radius: Style.radius + color: retryHover.hovered ? Tokens.tint10 : "transparent" + border.width: 1 + border.color: Tokens.line + Text { + anchors.centerIn: parent + text: "Try again" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + HoverHandler { id: retryHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: page.load() } + } + } +} diff --git a/client/pages/PlaylistPage.qml b/client/pages/PlaylistPage.qml new file mode 100644 index 0000000..0744777 --- /dev/null +++ b/client/pages/PlaylistPage.qml @@ -0,0 +1,569 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../lib/ids.js" as Ids + +// The playlist page, ported from ui/src/routes/playlist/[id]/+page.svelte. get_playlist(id) once, +// then get_playlist_more on the scroll sentinel — a five-figure Liked Songs list stays a single +// reused TrackList so scrolling never mounts more than a couple of screenfuls. The visual system is +// spec §5: a PageHero (art, eyebrow, title, meta, Play/like/more), a slim controls row (Shuffle, +// sort, the 80 ms filter box), the optional description, then a TrackList with a table header. Smart +// and local playlists drop the controls YouTube can't do. All data flows are unchanged. +Item { + id: page + + readonly property var params: Router.current ? Router.current.params : ({}) + readonly property string playlistId: page.params && page.params.id ? page.params.id : "" + + property var pl: null + property bool loading: true + property string errorMsg: "" + property bool loadingMore: false + property bool moreError: false + property string query: "" + property string applied: "" + property bool expanded: false + property string sortKey: "default" + property bool busy: false + property bool confirmingDelete: false + property bool editing: false + + readonly property bool isLiked: page.playlistId === "VLLM" + readonly property bool isSmart: Ids.isSmartPlaylistId(page.playlistId) + readonly property bool isLocal: String(page.playlistId).indexOf("RYOTUNES_LOCAL_PLAYLIST:") === 0 + readonly property bool owned: !!(page.pl && page.pl.owned) && !page.isLiked + readonly property bool hasSortMenu: !!(page.pl && page.pl.sortMenu) + readonly property var sorts: [ + { key: "default", label: "Default" }, + { key: "newest", label: "Newest first" }, + { key: "oldest", label: "Oldest first" }, + { key: "title", label: "Title" }, + { key: "artist", label: "Artist" }, + { key: "album", label: "Album" } + ] + readonly property var shown: { + var items = (page.pl && page.pl.items) ? page.pl.items : []; + var q = page.applied.trim().toLowerCase(); + if (!q) + return items; + return items.filter((t) => ( + (t.title && t.title.toLowerCase().indexOf(q) >= 0) + || (t.artists && t.artists.toLowerCase().indexOf(q) >= 0) + || (t.album && t.album.toLowerCase().indexOf(q) >= 0))); + } + + onParamsChanged: page.load() + Component.onCompleted: page.load() + + // 80 ms filter debounce (clearing is instant). + Timer { id: filterTimer; interval: 80; onTriggered: page.applied = page.query } + onQueryChanged: { + if (!page.query.trim()) { filterTimer.stop(); page.applied = ""; } + else filterTimer.restart(); + } + + // A device-playlist mutation to THIS playlist (a track added or removed, the cover set or reset) + // refreshes the header art in place. A full reload would yank the list to the top and out of edit + // mode, and remove() already keeps the rows in step with its optimistic splice. + Connections { + target: Daemon + function onEvent(name: string, data: var): void { + if (name === "library-changed" && data && data.id === page.playlistId) + page.refreshMeta(); + } + } + + function fetchSort(k) { return k === "plays" ? "default" : k; } + function sortLabel() { + for (var i = 0; i < page.sorts.length; i++) + if (page.sorts[i].key === page.sortKey) + return page.sorts[i].label; + return "Default"; + } + + // The Sonora meta line: prefer the daemon's formatted subtitle (owner / N tracks · duration); + // only synthesise a track count when no subtitle is present, so we never double up the count. + function metaLine() { + if (!page.pl) + return ""; + if (page.pl.subtitle) + return page.pl.subtitle; + var n = page.pl.items ? page.pl.items.length : 0; + return n ? (n + (n === 1 ? " track" : " tracks") + (page.pl.continuation ? "+" : "")) : ""; + } + function eyebrowText() { + if (page.isSmart) return "Smart playlist"; + if (page.isLocal) return "Local playlist"; + if (page.isLiked) return "Your library"; + return "Playlist"; + } + + function load() { + if (!page.playlistId) + return; + page.loading = true; + page.errorMsg = ""; + page.query = ""; + page.applied = ""; + page.expanded = false; + page.moreError = false; + page.sortKey = "default"; + page.confirmingDelete = false; + page.editing = false; + var reqId = page.playlistId; + Daemon.call("get_playlist", { id: page.playlistId }) + .then((p) => { + if (page.playlistId !== reqId) + return; + page.pl = p; + if (p.sortMenu && p.sortMenu.selected) + page.sortKey = p.sortMenu.selected; + page.loading = false; + // A fresh navigation lands at the top: a ListView with a tall header can otherwise + // settle with contentY > 0, clipping the hero. loadMore never calls this. + Qt.callLater(page.scrollTop); + }) + .catch((e) => { + if (page.playlistId !== reqId) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + }); + } + + // Re-read only the head fields (cover override, auto thumbnail, title, meta) after a mutation, + // leaving items/continuation/scroll/sort/filter/edit state untouched. cover serialises as null + // once reset, so the hero falls through to the auto collage. + function refreshMeta() { + if (!page.playlistId) + return; + var reqId = page.playlistId; + Daemon.call("get_playlist", { id: page.playlistId }) + .then((p) => { + if (!page.pl || page.playlistId !== reqId) + return; + page.pl = Object.assign({}, page.pl, { + cover: p.cover, + thumbnail: p.thumbnail, + subtitle: p.subtitle, + title: p.title + }); + }) + .catch(() => {}); + } + + function scrollTop() { + if (body.visible && body.view) + body.view.positionViewAtBeginning(); + } + + function loadMore() { + if (!page.pl || !page.pl.continuation || page.loadingMore || page.moreError) + return; + page.loadingMore = true; + var token = page.pl.continuation; + Daemon.call("get_playlist_more", { token: token }) + .then((more) => { + if (!page.pl || page.pl.continuation !== token) + return; + page.pl = Object.assign({}, page.pl, { + items: page.pl.items.concat(more.items), + continuation: more.items.length ? more.continuation : undefined + }); + page.loadingMore = false; + }) + .catch(() => { page.moreError = true; page.loadingMore = false; }); + } + function maybeLoadMore() { + if (!page.pl || !page.pl.continuation || page.loadingMore || page.moreError) + return; + if (body.view.contentHeight <= 0) + return; + if (body.view.contentY + body.view.height > body.view.contentHeight - 600) + page.loadMore(); + } + + // The played-from playlist as a navigable BrowseItem, for Personal recents. Cover overrides the + // auto thumbnail; a smart or local id is kept verbatim so it still resolves. + function asItem() { + return { + kind: "playlist", + id: page.playlistId, + title: page.pl ? page.pl.title : "Playlist", + subtitle: page.pl ? (page.pl.subtitle || "") : "", + thumbnail: page.pl ? (page.pl.cover || page.pl.thumbnail || "") : "" + }; + } + + function play(start) { + if (!page.pl) + return; + var at = start === null ? null : page.pl.items.indexOf(page.shown[start]); + var recent = page.asItem(); + Daemon.call("play_playlist", { + items: page.pl.items, + start: at === -1 ? null : at, + sourceId: page.isSmart ? undefined : page.playlistId, + sourceName: page.pl.title, + continuation: page.pl.continuation + }).then(() => Personal.noteRecent(recent)) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + function shuffle() { + if (!page.pl || !page.pl.items.length) + return; + var recent = page.asItem(); + Daemon.call("play_playlist", { + items: page.pl.items, start: null, + sourceId: page.isSmart ? undefined : page.playlistId, + sourceName: page.pl.title, shuffle: true, continuation: page.pl.continuation + }).then(() => Personal.noteRecent(recent)) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + function chooseSort(k) { + if (k === page.sortKey) + return; + page.sortKey = k; + var reqId = page.playlistId; + if (page.pl && page.pl.sortMenu && page.pl.sortMenu.editable) + Daemon.call("set_playlist_sort", { playlistId: page.playlistId, sort: page.fetchSort(k) }).catch(() => {}); + Daemon.call("get_playlist", { id: page.playlistId, sort: page.fetchSort(k), desc: false }) + .then((p) => { if (page.playlistId === reqId) page.pl = p; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not sort", "error")); + } + + function removeAt(index) { + var song = page.shown[index]; + if (!song || !song.set_video_id) + return; + var pid = page.playlistId; + page.pl = Object.assign({}, page.pl, { items: page.pl.items.filter((t) => t !== song) }); + Daemon.call("remove_from_playlist", { playlistId: pid, videoId: song.video_id, setVideoId: song.set_video_id }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not remove", "error")); + } + + function pickCover() { + Daemon.call("set_playlist_cover", { playlistId: page.playlistId, pick: true }) + .then((res) => { if (res) page.load(); }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not set cover", "error")); + } + function doDelete() { + Daemon.call("delete_playlist", { playlistId: page.playlistId }) + .then(() => { Playback.toast("Playlist deleted", "success"); Router.pop(); }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not delete", "error")); + } + function share() { + var raw = String(page.playlistId).replace(/^VL/, ""); + var url = "https://music.youtube.com/playlist?list=" + encodeURIComponent(raw); + Quickshell.execDetached(["sh", "-c", "printf %s \"$1\" | wl-copy", "sh", url]); + Playback.toast("Link copied", "success"); + } + function queuePlaylist(next) { + if (!page.pl || !page.pl.items.length) + return; + Daemon.call(next ? "play_next" : "add_to_queue", { + items: page.pl.items, from: page.pl.title, continuation: page.pl.continuation + }).then(() => Playback.toast(next ? "Playing next" : "Added to queue", "success")) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not queue", "error")); + } + function radio() { + if (page.isSmart || page.isLocal) + return; + Playback.toast("Starting radio…", "info"); + Daemon.call("start_radio", { kind: "playlist", id: page.playlistId, name: page.pl ? page.pl.title : null }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not start radio", "error")); + } + + function buildMenu() { + var out = []; + out.push({ icon: "arrow-up", label: "Play next", danger: false, act: () => page.queuePlaylist(true) }); + out.push({ icon: "queue", label: "Add to queue", danger: false, act: () => page.queuePlaylist(false) }); + if (!page.isLocal && page.pl && page.pl.items && page.pl.items.length) + out.push({ icon: "download", label: Downloads.addingAlbum ? "Adding playlist\u2026" : "Download playlist", + danger: false, act: () => Downloads.enqueueCollectionFromPage(page.pl, "playlist") }); + if (!page.isSmart && !page.isLocal) + out.push({ icon: "radio", label: "Start radio", danger: false, act: () => page.radio() }); + if (page.owned) { + out.push({ icon: "edit", label: "Edit details", danger: false, act: () => page.editing = true }); + if (!page.isLocal) + out.push({ icon: "music", label: "Change cover", danger: false, act: () => page.pickCover() }); + out.push({ icon: "close", label: "Delete playlist", danger: true, act: () => page.confirmingDelete = true }); + } + if (!page.isSmart && !page.isLocal) + out.push({ icon: "link", label: "Share", danger: false, act: () => page.share() }); + return out; + } + + Text { + anchors.centerIn: parent + visible: page.loading || page.errorMsg !== "" + text: page.loading ? "Loading playlist…" : page.errorMsg + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + TrackList { + id: body + anchors.fill: parent + visible: !page.loading && page.errorMsg === "" && page.pl !== null + items: page.shown + showHeader: true + showAlbum: true + showPlays: true + canAdd: !page.isLocal + canRemove: page.owned + removeLabel: "Remove from playlist" + source: page.pl ? page.pl.title : "" + onActivated: (i) => page.play(i) + onRemoveAt: (i) => page.removeAt(i) + header: plHeader + footer: plFooter + Component.onCompleted: body.view.contentYChanged.connect(page.maybeLoadMore) + } + + Component { + id: plHeader + Item { + width: body.view.width + implicitHeight: headerCol.implicitHeight + Style.sp(3) + + ColumnLayout { + id: headerCol + width: parent.width + spacing: Style.sp(4) + + PageHero { + id: hero + Layout.fillWidth: true + eyebrow: page.eyebrowText() + title: (page.pl && page.pl.title) ? page.pl.title : "Playlist" + meta: page.metaLine() + art: (page.pl && (page.pl.cover || page.pl.thumbnail)) ? (page.pl.cover || page.pl.thumbnail) : "" + placeholderIcon: page.isSmart ? "on-repeat" : "playlist" + primaryLabel: "Play" + likeable: false + showMore: true + onPrimary: page.play(null) + onMore: { + var p = hero.mapToItem(page, Style.sp(44), hero.height - Style.sp(8)); + plMenu.openAt(p.x, p.y); + } + } + + // controls: Shuffle, sort, filter + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Btn { + text: "Shuffle" + icon: "shuffle" + enabled: !!(page.pl && page.pl.items && page.pl.items.length) + onClicked: page.shuffle() + } + Btn { + id: sortBtn + visible: page.hasSortMenu + text: page.sortLabel() + icon: "sort" + onClicked: { + var p = sortBtn.mapToItem(page, 0, sortBtn.height); + sortMenu.openAt(p.x, p.y); + } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: Style.sp(52) + Layout.alignment: Qt.AlignVCenter + implicitHeight: Style.ctlH + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: plFilter.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.sm; color: Tokens.inkMuted } + TextInput { + id: plFilter + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + text: page.query + onTextChanged: page.query = text + Text { + anchors.verticalCenter: parent.verticalCenter + visible: plFilter.text.length === 0 + text: "Filter" + color: Tokens.inkFaint + font: plFilter.font + } + } + } + } + } + + // description (collapsible) + ColumnLayout { + Layout.fillWidth: true + visible: !!(page.pl && page.pl.description) + spacing: Style.sp(0.5) + Text { + Layout.fillWidth: true + text: (page.pl && page.pl.description) ? page.pl.description : "" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + maximumLineCount: page.expanded ? 999 : 2 + elide: Text.ElideRight + } + Text { + visible: !!(page.pl && page.pl.description && page.pl.description.length > 120) + text: page.expanded ? "LESS" : "MORE" + color: descHover.hovered ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + HoverHandler { id: descHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: page.expanded = !page.expanded } + } + } + } + } + } + + Component { + id: plFooter + Item { + width: body.view.width + implicitHeight: Style.sp(24) + ColumnLayout { + anchors.centerIn: parent + spacing: Style.sp(1) + Text { + Layout.alignment: Qt.AlignHCenter + visible: page.loadingMore + text: "Loading more…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + Rectangle { + Layout.alignment: Qt.AlignHCenter + visible: page.moreError + implicitWidth: Style.sp(24); implicitHeight: Style.ctlH + radius: Style.radius + color: tryHover.hovered ? Tokens.tint10 : "transparent" + border.width: 1; border.color: Tokens.line + Text { anchors.centerIn: parent; text: "Try again"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.sm } + HoverHandler { id: tryHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { page.moreError = false; page.loadMore(); } } + } + Text { + Layout.alignment: Qt.AlignHCenter + visible: !page.loadingMore && !page.moreError && !!(page.pl && !page.pl.continuation) && !page.applied.trim() && !!(page.pl && page.pl.items.length) + text: (page.pl ? page.pl.items.length : 0) + " tracks" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.alignment: Qt.AlignHCenter + visible: !!page.applied.trim() && page.shown.length === 0 + text: "No tracks match \u201C" + page.applied.trim() + "\u201D." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + } + + Menu { + id: plMenu + customItems: page.buildMenu() + } + Menu { + id: sortMenu + customItems: page.sorts.map((s) => ({ icon: page.sortKey === s.key ? "check-circle" : "sort", + label: s.label, danger: false, act: () => page.chooseSort(s.key) })) + } + + // --- edit details dialog --------------------------------------------------------------- + Loader { + anchors.fill: parent + active: page.editing + sourceComponent: editDialog + } + Component { + id: editDialog + EditPlaylist { + blurSource: body + playlistId: page.playlistId + initialName: (page.pl && page.pl.title) ? page.pl.title : "" + initialDescription: (page.pl && page.pl.description) ? page.pl.description : "" + initialPublic: !!(page.pl && page.pl.privacy === "PUBLIC") + onClosed: page.editing = false + onSaved: { page.editing = false; page.load(); } + } + } + + // --- delete confirm -------------------------------------------------------------------- + Item { + anchors.fill: parent + visible: page.confirmingDelete + z: 220 + MouseArea { anchors.fill: parent; onClicked: page.confirmingDelete = false } + Rectangle { anchors.fill: parent; color: "#000000"; opacity: 0.5 } + Rectangle { + anchors.centerIn: parent + width: Style.sp(90) + implicitHeight: delCol.implicitHeight + Style.sp(12) + height: implicitHeight + radius: Style.radiusCard + color: Tokens.paper + border.width: 1 + border.color: Tokens.line + MouseArea { anchors.fill: parent } + ColumnLayout { + id: delCol + anchors.fill: parent + anchors.margins: Style.sp(6) + spacing: Style.sp(3) + Text { + text: "Delete this playlist?" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + text: "This removes it from your account. It can't be undone." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Style.sp(2) + Pill { label: "Cancel"; onClicked: page.confirmingDelete = false } + Pill { label: "Delete"; active: true; onClicked: { page.confirmingDelete = false; page.doDelete(); } } + } + } + } + } +} diff --git a/client/pages/RadioPage.qml b/client/pages/RadioPage.qml new file mode 100644 index 0000000..bec457a --- /dev/null +++ b/client/pages/RadioPage.qml @@ -0,0 +1,391 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The internet-radio directory, ported from ui/src/routes/radio/+page.svelte. Discovery is strictly +// demand-driven: radio_stations is called on open, on a search, and on "load more" — never on a +// timer — so an idle Radio page contacts nothing. Stations paginate 36 at a time into a reuseItems +// GridView; a generation counter drops the reply of a superseded query. play_radio_station streams +// the station through the same engine, open_external opens a station's site in the real browser. +Item { + id: page + + readonly property int pageSize: 36 + + property string input: "" + property string query: "" + property var stations: [] + property bool loading: false + property bool loadingMore: false + property string errorMsg: "" + property bool hasMore: true + property string playing: "" + // Bumped on every reset so a slow reply from a previous query is discarded, not rendered. + property int generation: 0 + + Component.onCompleted: page.load(true) + + function load(reset) { + if ((reset && page.loading) || (!reset && (page.loadingMore || !page.hasMore))) + return; + var myGen = reset ? ++page.generation : page.generation; + var offset = reset ? 0 : page.stations.length; + if (reset) { + page.loading = true; + page.errorMsg = ""; + } else { + page.loadingMore = true; + } + Daemon.call("radio_stations", { query: page.query, offset: offset, limit: page.pageSize }) + .then((rows) => { + if (myGen !== page.generation) + return; + rows = rows || []; + if (reset) { + page.stations = rows; + } else { + var seen = ({}); + page.stations.forEach((s) => { seen[s.stationUuid] = true; }); + page.stations = page.stations.concat(rows.filter((s) => !seen[s.stationUuid])); + } + page.hasMore = rows.length === page.pageSize; + page.loading = false; + page.loadingMore = false; + }) + .catch((e) => { + if (myGen !== page.generation) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.loading = false; + page.loadingMore = false; + }); + } + + function search() { + page.query = page.input.trim().replace(/\s+/g, " "); + page.load(true); + } + function clearSearch() { + page.input = ""; + page.query = ""; + page.load(true); + } + + function playStation(station) { + if (page.playing) + return; + page.playing = station.stationUuid; + Daemon.call("play_radio_station", { stationUuid: station.stationUuid }) + .then(() => { page.playing = ""; Playback.toast("Playing " + station.name, "success"); }) + .catch((e) => { page.playing = ""; Playback.toast((e && e.message) ? e.message : String(e), "error"); }); + } + function openHomepage(station) { + if (!station.homepage) + return; + Daemon.call("open_external", { url: station.homepage }) + .catch((e) => Playback.toast((e && e.message) ? e.message : String(e), "error")); + } + + function detail(station) { + var parts = [ + station.countryCode || station.country, + station.codec, + (station.bitrate && station.bitrate > 0) ? (station.bitrate + " kbps") : "" + ].filter(Boolean); + return parts.join(" · ") || "Live stream"; + } + function stationTags(station) { + return (station.tags || "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 3); + } + + // App draws the Backdrop + paper wash behind the page; no full-bleed paper base here, and the + // page fills the padded rect App gives it with no outer margins of its own. + GridView { + id: grid + anchors.fill: parent + clip: true + reuseItems: true + cacheBuffer: Math.max(0, Math.round(height * 1.5)) + boundsBehavior: Flickable.StopAtBounds + topMargin: 0 + bottomMargin: Style.sp(20) + // The full grid clears while a fresh query resolves, mirroring the Svelte loading branch; + // paging keeps the current cards and only appends beneath them. + model: page.loading ? [] : page.stations + + // The same column maths CardGrid uses: fill the width in ~192 px columns. + cellWidth: Math.floor((width - 1) / Math.max(1, Math.floor(width / Style.sp(48)))) + cellHeight: Style.sp(74) + + // --- hero + search console + state ------------------------------------------------- + header: Item { + width: grid.width + implicitHeight: heroCol.implicitHeight + Style.sp(4) + + ColumnLayout { + id: heroCol + width: parent.width + spacing: Style.sp(4) + + PageHero { + Layout.fillWidth: true + eyebrow: "RADIO" + title: "Airwaves" + meta: (page.query ? "Results for “" + page.query + "”" : "Top stations") + + " · " + page.stations.length + " stations" + placeholderIcon: "radio" + likeable: false + showMore: false + primaryLabel: "Top stations" + onPrimary: page.clearSearch() + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { + Layout.preferredWidth: Style.sp(90) + Layout.maximumWidth: Style.sp(120) + Layout.preferredHeight: Style.sp(10) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: searchField.activeFocus ? Tokens.lineStrong : Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(2) + Icon { name: "search"; size: Style.fs.md; color: Tokens.inkMuted } + TextInput { + id: searchField + Layout.fillWidth: true + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: page.input + onTextChanged: page.input = text + onAccepted: page.search() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: searchField.text.length === 0 + text: "Search stations by name…" + color: Tokens.inkFaint + font: searchField.font + } + } + } + } + Pill { label: "Search"; icon: "search"; primary: true; enabled: !page.loading; onClicked: page.search() } + Item { Layout.fillWidth: true } + } + + Hairline { Layout.fillWidth: true } + + // Loading / error / empty state, matching the Svelte branches. Flows under the + // console so the hero and search stay put while a query resolves. + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(4) + spacing: Style.sp(2) + visible: page.loading || page.stations.length === 0 + + Text { + text: page.loading ? "TUNING" + : (page.errorMsg ? "SIGNAL LOST" : "NO MATCH") + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: page.loading ? "Finding live stations." + : (page.errorMsg ? "Radio directory unavailable." : "No stations found.") + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(140) + text: page.loading ? "Trying available Radio Browser mirrors without blocking the player." + : (page.errorMsg ? page.errorMsg : "Try a shorter station name or return to the popular directory.") + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Pill { + visible: !page.loading && (page.errorMsg !== "" || page.query !== "") + label: page.errorMsg ? "Try again" : "Top stations" + onClicked: page.errorMsg ? page.load(true) : page.clearSearch() + } + } + } + } + + // --- pager -------------------------------------------------------------------------- + footer: Item { + width: grid.width + implicitHeight: (!page.loading && page.stations.length > 0) ? Style.sp(20) : 0 + visible: !page.loading && page.stations.length > 0 + ColumnLayout { + anchors.centerIn: parent + spacing: Style.sp(1) + Pill { + Layout.alignment: Qt.AlignHCenter + visible: page.hasMore + label: page.loadingMore ? "Finding more…" : "Load more stations" + enabled: !page.loadingMore + onClicked: page.load(false) + } + Text { + Layout.alignment: Qt.AlignHCenter + visible: !page.hasMore + text: "END OF THIS SIGNAL SET" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + + // --- station card: art 96, name md, tags micro (tracked) --------------------------- + delegate: Item { + id: cell + required property var modelData + width: grid.cellWidth + height: grid.cellHeight + + Rectangle { + anchors.fill: parent + anchors.margins: Style.sp(1.5) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: cardHover.hovered ? Tokens.lineStrong : Tokens.line + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.sp(3) + spacing: Style.sp(2) + + // favicon over the neutral plate, with a LIVE badge and a radio fallback. + Item { + id: artBox + Layout.preferredWidth: Style.sp(24) + Layout.preferredHeight: Style.sp(24) + Layout.alignment: Qt.AlignLeft + + Artwork { + anchors.fill: parent + url: cell.modelData.favicon || "" + px: Style.sp(24) + placeholderIcon: "radio" + } + Rectangle { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: Style.sp(1) + implicitWidth: liveTag.implicitWidth + Style.sp(2) + implicitHeight: liveTag.implicitHeight + Style.sp(1) + radius: Style.sp(1) + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, 0.82) + Text { + id: liveTag + anchors.centerIn: parent + text: "LIVE" + color: Tokens.sun + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + + Text { + Layout.fillWidth: true + text: cell.modelData.name + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.DemiBold + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + text: page.detail(cell.modelData) + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + visible: page.stationTags(cell.modelData).length > 0 + Repeater { + model: page.stationTags(cell.modelData) + delegate: Rectangle { + id: tagChip + required property var modelData + implicitWidth: tagText.implicitWidth + Style.sp(3) + implicitHeight: Style.sp(5) + radius: Style.radius + color: "transparent" + border.width: 1 + border.color: Tokens.lineSoft + Text { + id: tagText + anchors.centerIn: parent + text: tagChip.modelData.toUpperCase() + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + } + } + Item { Layout.fillWidth: true } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Pill { + label: (page.playing === cell.modelData.stationUuid) ? "Tuning…" : "Play" + icon: "play" + primary: true + enabled: !page.playing + onClicked: page.playStation(cell.modelData) + } + Pill { + visible: !!cell.modelData.homepage + label: "Site" + icon: "link" + onClicked: page.openHomepage(cell.modelData) + } + Item { Layout.fillWidth: true } + } + } + HoverHandler { id: cardHover } + } + } + } +} diff --git a/client/pages/SearchPage.qml b/client/pages/SearchPage.qml new file mode 100644 index 0000000..c2cce04 --- /dev/null +++ b/client/pages/SearchPage.qml @@ -0,0 +1,579 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" +import "../chrome" +import "../lib/browse.js" as Browse +import "../lib/ids.js" as Ids + +// The search page, ported from ui/src/routes/search/+page.svelte. The field runs one mixed page +// (search_all) plus one songs page (search_page) in parallel, then lays the result out as the same +// categorised sections the Svelte page has — Top (4), Songs (6), Albums (5), Artists (3), Playlists +// (5) — each with a "Show more" that expands that category in place (search_cards / search_page_more, +// the folded-in search-more route). The SearchSuggest typeahead handles quick keyboard navigation. +Item { + id: page + + property string query: "" + property var res: null + property var songs: [] + property string searched: "" + property bool searching: false + property string errorMsg: "" + property var history: [] + property string latest: "" + + // expanded category ("" = the sections view) + property string expandedCat: "" + property var expandedItems: [] + property string expandedCont: "" + property bool expandedLoading: false + + // Spotify is selected but not signed in: show the sign-in empty state instead of searching the + // daemon (search is gated on a signed-in Spotify account). + readonly property bool spotifyGate: Playback.provider === "spotify" && !(Playback.spotify && Playback.spotify.signedIn) + + // A ?q= arrival (the Ctrl+K palette's "All results", or a card's search intent) runs the search. + readonly property var params: Router.current ? Router.current.params : ({}) + onParamsChanged: page.applyParams() + Component.onCompleted: page.applyParams() + function applyParams() { + if (page.params && page.params.q && page.params.q !== page.searched) { + page.query = page.params.q; + page.runSearch(); + } + } + + // A provider switch swaps the catalogue: re-run the current query against the new provider (or + // fall to the sign-in card). + Connections { + target: Playback + function onProviderChanged(): void { + if (page.spotifyGate || page.searched === "") + return; + page.res = null; + page.songs = []; + page.expandedCat = ""; + page.query = page.searched; + page.runSearch(); + } + } + + readonly property var songRows: { + var s = page.songs.length ? page.songs : ((page.res && page.res.songs) ? page.res.songs.map(Browse.asSong) : []); + return s.filter((song) => !song.is_video); + } + readonly property var sections: { + if (!page.res) + return []; + var out = [ + { key: "top", label: "Top results", items: page.res.top, max: 4, more: false, list: false }, + { key: "songs", label: "Songs", items: page.res.songs, max: 6, more: true, list: true }, + { key: "albums", label: "Albums", items: page.res.albums, max: 5, more: true, list: false }, + { key: "artists", label: "Artists", items: page.res.artists, max: 3, more: true, list: false }, + { key: "playlists", label: "Playlists", items: page.res.playlists, max: 5, more: true, list: false } + ]; + return out.filter((s) => s.list ? page.songRows.length : (s.items && s.items.length)); + } + + // Derived views onto the result shape for the rebuilt layout: the single hero Top result, the + // capped song rows the TrackList shows, and the album/artist/playlist card shelves. + readonly property var topItem: (page.res && page.res.top && page.res.top.length) ? page.res.top[0] : null + readonly property int songsMax: 6 + readonly property var songsShown: page.songRows.slice(0, page.songsMax) + readonly property var cardSections: page.sections.filter((s) => !s.list && s.key !== "top") + + function rememberQuery(q) { + if (!q) + return; + var next = [q]; + for (var i = 0; i < page.history.length; i++) + if (page.history[i].toLowerCase() !== q.toLowerCase()) + next.push(page.history[i]); + page.history = next.slice(0, 6); + } + + function runSearch() { + var q = page.query.trim().replace(/\s+/g, " "); + // Gated: no daemon call, the sign-in card carries the page. + if (!q || page.spotifyGate) + return; + page.latest = q; + page.searched = q; + page.expandedCat = ""; + page.rememberQuery(q); + if (!page.res || page.searched !== q) + page.searching = true; + page.errorMsg = ""; + Promise.all([ + Daemon.call("search_all", { query: q }), + Daemon.call("search_page", { query: q }).catch(() => ({ items: [] })) + ]).then((r) => { + if (page.latest !== q) + return; + page.res = r[0]; + page.songs = r[1].items || []; + page.searched = q; + page.searching = false; + // Land the results list at the top: a ListView with a tall header (the Top-result card) + // can otherwise settle with contentY > 0 and clip the card. + Qt.callLater(page.scrollTop); + }).catch((e) => { + if (page.latest !== q) + return; + page.errorMsg = (e && e.message) ? e.message : String(e); + page.searching = false; + }); + } + + function scrollTop() { + if (body.visible && body.view) + body.view.positionViewAtBeginning(); + } + + function playSong(song) { + Playback.play(song).catch((e) => Playback.toast((e && e.message) ? e.message : "Could not play", "error")); + } + + // The hero Top result: a click opens it (a song plays, a collection routes), the Play affordance + // plays it — mirroring MediaCard so an album/playlist top hit still starts straight from the card. + function openTop(item) { + if (!item) + return; + if (item.kind === "song") + Playback.play(Browse.asSong(item)); + else + Router.push(item.kind, { id: item.id, title: item.title }); + } + function playTop(item) { + if (!item) + return; + if (item.kind === "song") { + page.playSong(Browse.asSong(item)); + } else if (item.kind === "album") { + Daemon.call("get_album", { id: item.id }) + .then((a) => Daemon.call("play_playlist", { items: a.items, sourceId: a.playlistId, sourceName: item.title })) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } else if (item.kind === "playlist") { + Daemon.call("get_playlist", { id: item.id }) + .then((p) => Daemon.call("play_playlist", { + items: p.items, + sourceId: Ids.isSmartPlaylistId(item.id) ? undefined : item.id, + sourceName: item.title, + continuation: p.continuation + })) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } else { + Router.push(item.kind, { id: item.id, title: item.title }); + } + } + + function showMore(sec) { + page.expandedCat = sec.key; + page.expandedItems = []; + page.expandedCont = ""; + page.expandedLoading = true; + if (sec.key === "songs") { + Daemon.call("search_page", { query: page.searched }) + .then((r) => { page.expandedItems = (r.items || []).filter((s) => !s.is_video); page.expandedCont = r.continuation || ""; page.expandedLoading = false; }) + .catch(() => page.expandedLoading = false); + } else { + Daemon.call("search_cards", { query: page.searched, category: sec.key }) + .then((items) => { page.expandedItems = items || []; page.expandedLoading = false; }) + .catch(() => page.expandedLoading = false); + } + } + function loadMoreExpanded() { + if (page.expandedCat !== "songs" || !page.expandedCont || page.expandedLoading) + return; + page.expandedLoading = true; + var token = page.expandedCont; + Daemon.call("search_page_more", { token: token }) + .then((r) => { + page.expandedItems = page.expandedItems.concat((r.items || []).filter((s) => !s.is_video)); + page.expandedCont = r.continuation || ""; + page.expandedLoading = false; + }) + .catch(() => page.expandedLoading = false); + } + + // --- sections view ------------------------------------------------------------------------- + // The field sits fixed above the results (its typeahead dropdown then overlays them), and the + // resolved sections scroll inside one TrackList: the Top card and Songs heading ride in the + // header, the song rows are the list body, and the card shelves ride in the footer. + ColumnLayout { + id: root + anchors.fill: parent + spacing: Style.sp(4) + visible: page.expandedCat === "" && !page.spotifyGate + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + text: "// MUSIC / DISCOVERY" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + text: "Search" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + } + } + + SearchSuggest { + id: suggest + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(160) + value: page.query + onValueChanged: page.query = value + onSubmitted: page.runSearch() + onPicked: page.query = value + z: 40 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + TrackList { + id: body + anchors.fill: parent + visible: !page.searching && !!page.res && page.sections.length > 0 + items: page.songsShown + showHeader: true + showAlbum: true + showPlays: true + canAdd: true + onActivated: (i) => { if (page.songsShown[i]) page.playSong(page.songsShown[i]); } + header: searchHeader + footer: searchFooter + } + + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Style.sp(2) + visible: page.searching + text: "Resolving songs, artists, albums and playlists…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Style.sp(2) + visible: page.errorMsg !== "" + text: page.errorMsg + color: Style.alert + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Style.sp(2) + visible: !page.searching && !!page.res && page.sections.length === 0 + text: "No results for \u201C" + page.searched + "\u201D." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + // idle: recent searches + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.sp(2) + visible: !page.searching && !page.res && page.errorMsg === "" + spacing: Style.sp(2) + Text { + text: "RECENT SEARCHES" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Repeater { + model: page.history + delegate: Rectangle { + id: histRow + required property var modelData + required property int index + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: hHover.hovered ? Tokens.tint5 : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(3) + Text { text: String(histRow.index + 1).padStart(2, "0"); color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.sm } + Text { Layout.fillWidth: true; text: histRow.modelData; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight } + } + HoverHandler { id: hHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { page.query = histRow.modelData; page.runSearch(); } } + } + } + Text { + visible: page.history.length === 0 + text: "Search songs, artists, albums and playlists. Press Ctrl K from anywhere." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + } + } + } + } + + // header: the Top result card + the Songs heading, riding above the song rows. + Component { + id: searchHeader + Item { + width: body.view.width + implicitHeight: headerCol.implicitHeight + Style.sp(6) + + ColumnLayout { + id: headerCol + width: parent.width + spacing: Style.sp(6) + + // Top result — a wide card + ColumnLayout { + Layout.fillWidth: true + visible: page.topItem !== null + spacing: Style.sp(3) + + SectionHeading { + Layout.fillWidth: true + title: "Top result" + } + + Rectangle { + id: topCard + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(140) + implicitHeight: topRow.implicitHeight + Style.sp(6) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: topHover.hovered ? Tokens.lineStrong : Tokens.line + Behavior on border.color { ColorAnimation { duration: Style.motion.snap } } + + HoverHandler { id: topHover } + + RowLayout { + id: topRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.sp(3) + anchors.rightMargin: Style.sp(3) + spacing: Style.sp(3) + + Artwork { + Layout.preferredWidth: Style.sp(24) + Layout.preferredHeight: Style.sp(24) + url: (page.topItem && page.topItem.thumbnail) ? page.topItem.thumbnail : "" + px: Style.sp(24) + round: !!(page.topItem && page.topItem.kind === "artist") + placeholderIcon: (page.topItem && page.topItem.kind === "artist") ? "user" + : (page.topItem && page.topItem.kind === "album") ? "cd" + : (page.topItem && page.topItem.kind === "playlist") ? "playlist" : "music" + } + + // identity block — clicking it opens the item + MouseArea { + Layout.fillWidth: true + Layout.fillHeight: true + cursorShape: Qt.PointingHandCursor + onClicked: page.openTop(page.topItem) + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: page.topItem ? page.topItem.kind.toUpperCase() : "" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + text: page.topItem ? page.topItem.title : "" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: !!(page.topItem && page.topItem.subtitle) + text: (page.topItem && page.topItem.subtitle) ? page.topItem.subtitle : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + } + } + } + + Btn { + Layout.alignment: Qt.AlignVCenter + visible: !!(page.topItem && page.topItem.kind !== "artist") + text: "Play" + icon: "play" + primary: true + onClicked: page.playTop(page.topItem) + } + } + } + } + + // Songs heading — the list rows below carry the songs + SectionHeading { + Layout.fillWidth: true + visible: page.songRows.length > 0 + title: "Songs" + more: true + onMoreClicked: page.showMore({ key: "songs" }) + } + } + } + } + + // footer: album / artist / playlist card shelves, each with an in-place "Show more". + Component { + id: searchFooter + Item { + width: body.view.width + implicitHeight: footerCol.implicitHeight + Style.sp(20) + + ColumnLayout { + id: footerCol + width: parent.width + spacing: Style.sp(6) + + Repeater { + model: page.cardSections + delegate: ColumnLayout { + id: secCol + required property var modelData + Layout.fillWidth: true + Layout.topMargin: Style.sp(3) + spacing: Style.sp(3) + + SectionHeading { + Layout.fillWidth: true + title: secCol.modelData.label + more: secCol.modelData.more + onMoreClicked: page.showMore(secCol.modelData) + } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Style.sp(66) + orientation: ListView.Horizontal + flickableDirection: Flickable.HorizontalFlick + boundsBehavior: Flickable.StopAtBounds + snapMode: ListView.SnapOneItem + reuseItems: true + clip: true + cacheBuffer: Math.round(width) + spacing: Style.sp(3) + model: secCol.modelData.items.slice(0, secCol.modelData.max) + delegate: MediaCard { + required property var modelData + item: modelData + cardWidth: Style.sp(40) + } + } + } + } + } + } + } + + // --- expanded category view ---------------------------------------------------------------- + Item { + anchors.fill: parent + visible: page.expandedCat !== "" && !page.spotifyGate + + RowLayout { + id: backBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.sp(2) + spacing: Style.sp(2) + IconButton { + icon: "arrow-left" + iconSize: Style.fs.lg + diameter: Style.sp(9) + onClicked: page.expandedCat = "" + } + Text { + Layout.fillWidth: true + text: page.expandedCat.toUpperCase() + " · \u201C" + page.searched + "\u201D" + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.lg + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + + // songs expanded + TrackList { + anchors.top: backBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Style.sp(3) + visible: page.expandedCat === "songs" + items: page.expandedItems + showHeader: true + showAlbum: true + showPlays: true + canAdd: true + onActivated: (i) => { if (page.expandedItems[i]) page.playSong(page.expandedItems[i]); } + Component.onCompleted: view.contentYChanged.connect(page.loadMoreExpanded) + } + + // cards expanded + CardGrid { + anchors.top: backBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Style.sp(3) + visible: page.expandedCat !== "" && page.expandedCat !== "songs" + pad: 0 + loading: page.expandedLoading && page.expandedItems.length === 0 + model: page.expandedItems + emptyText: "No results." + } + } + + // The Spotify sign-in empty state: shown when Spotify is selected but not signed in, in place of + // the search surface. The button starts the OAuth flow; nothing here touches the daemon. + SpotifyGate { + anchors.centerIn: parent + visible: page.spotifyGate + } +} diff --git a/client/pages/SettingsPage.qml b/client/pages/SettingsPage.qml new file mode 100644 index 0000000..92be7ab --- /dev/null +++ b/client/pages/SettingsPage.qml @@ -0,0 +1,1496 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// The settings surface, ported from ui/src/lib/components/SettingsDialog.svelte (plus the account, +// local-folder and playlist-transfer controls the Svelte app spreads across its titlebar and +// library). Every UI_SETTINGS key the daemon accepts round-trips through set_setting; the theme +// mode drives Style.themeMode, which pins Tokens to the local light/dark palette so the whole chrome +// re-renders. File choices use zenity (the same picker the library Local tab already adopted; the +// daemon dropped its Tauri dialogs for an explicit path). Nothing here polls: discord status is +// fetched on open and after a toggle, never on an interval, honouring the no-idle-timer rule. +// +// Visual layer: a left section rail (unchanged navigation + lazy-load data flow) beside a scrolling +// body of two-column setting rows (label md + description sm | control) grouped under SectionHeadings, +// per spec section 5. The page fills the padded rect App gives it — no outer margins, no opaque paper +// base (App draws the Backdrop + paper@0.88 behind it); local card fills only. +Item { + id: page + + property string section: "general" + property var settings: ({}) + property var clients: [] + property bool loaded: false + property string discordStatus: "disabled" + property var folders: [] + property bool foldersLoaded: false + property var identities: [] + property bool clearing: false + property bool importing: false + property bool exporting: false + + // Editable-field mirrors, seeded on load so typing never fights the settings binding. + property string proxyInput: "" + property string discordNameInput: "Ryotunes" + property bool forkOpen: false + property string forkName: "" + + // Software updates (About). Never polled — a check runs only on the button. The daemon dispatches + // requests concurrently and imposes no RPC timeout, so the long install call simply resolves when + // it is done; no background job protocol is needed. + property var updateInfo: null + property string updateStatus: "idle" // idle | checking | installing | installed | error + property string updateError: "" + property string installedVersion: "" + property bool showReleaseNotes: false + // The client (APP) version ships as a plain-text file in the QML package, stamped per release. + // Kept separate from the daemon handshake version so a stale daemon stays visible after an update. + property string appVersion: "" + + readonly property var qualities: [ + { id: "LOW", l: "Low" }, + { id: "AUTO", l: "Auto" }, + { id: "HIGH", l: "High" } + ] + readonly property var uiScales: [80, 90, 100, 110, 120, 130, 140] + readonly property var sections: [ + { k: "general", l: "General", jp: "全般" }, + { k: "playback", l: "Playback", jp: "再生" }, + { k: "downloads", l: "Downloads", jp: "保存" }, + { k: "data", l: "Data & storage", jp: "保存" }, + { k: "account", l: "Account", jp: "鍵" }, + { k: "local", l: "Local music", jp: "音源" }, + { k: "playlists", l: "Playlists", jp: "転送" }, + { k: "diagnostics", l: "Diagnostics", jp: "診" }, + { k: "about", l: "About", jp: "力" } + ] + + Component.onCompleted: { + page.load(); + var requested = Router.current && Router.current.params ? Router.current.params.section : ""; + if (page.sections.some((s) => s.k === requested)) page.selectSection(requested); + } + + // --- data ------------------------------------------------------------------------------ + function load() { + Promise.all([ + Daemon.call("get_settings").catch(() => ({})), + Daemon.call("get_stream_clients").catch(() => []) + ]).then((res) => { + page.settings = res[0] || ({}); + page.clients = res[1] || []; + page.proxyInput = page.settings.proxy || ""; + page.discordNameInput = (page.settings.discord_presence_name || "").trim() || "Ryotunes"; + page.loaded = true; + page.loadDiscord(); + }).catch((e) => { page.loaded = true; Playback.toast((e && e.message) ? e.message : String(e), "error"); }); + } + + function selectSection(k) { + page.section = k; + scroll.contentY = 0; + if (k === "local" && !page.foldersLoaded) page.scanFolders(); + if (k === "account") page.loadIdentities(); + if (k === "general") page.loadDiscord(); + } + + // --- appearance (skin) ----------------------------------------------------------------- + function chooseSkin(id) { Prefs.skin = id; Prefs.save(); } + function skinMode(m) { return (m && m["default"] === "light") ? "light" : "dark"; } + function skinSwatches(entry) { + var mode = page.skinMode(entry.manifest); + var p = Object.assign({}, Skin.fallback.modes[mode], Skin.palette(mode, entry.manifest)); + return [p.paper, p.paperLift, p.ink, p.sun, p.bone]; + } + function skinBadge(entry) { return entry.generated ? "GENERATED" : entry.source.toUpperCase(); } + function kebab(s) { + return (s || "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + } + function createSkin() { + var id = page.kebab(page.forkName); + if (!id) { Playback.toast("Name the new skin first", "error"); return; } + var path = Skin.forkCurrent(id); + Prefs.skin = id; Prefs.save(); + Quickshell.execDetached(["xdg-open", path]); + page.forkOpen = false; page.forkName = ""; + Playback.toast("Created skin “" + id + "” — opening it to edit", "success"); + } + + function applyLocal(key, value) { + var s = ({}); + for (var k in page.settings) s[k] = page.settings[k]; + s[key] = value; + page.settings = s; + // Keep the chrome that binds Playback.settings (the titlebar's Discord light) coherent. + var ps = ({}); + for (var k2 in Playback.settings) ps[k2] = Playback.settings[k2]; + ps[key] = value; + Playback.settings = ps; + } + function setSetting(key, value) { + page.applyLocal(key, value); + return Daemon.call("set_setting", { key: key, value: value }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not save setting", "error")); + } + + function loadDiscord() { + Daemon.call("discord_status") + .then((d) => { if (d && d.status) page.discordStatus = d.status; }) + .catch(() => {}); + } + function setDiscord(on) { + page.setSetting("discord_rpc", on ? "true" : "false").then(() => page.loadDiscord()); + } + function saveDiscordName() { + var value = page.discordNameInput.trim() || "Ryotunes"; + var n = value.length; + if (n < 2 || n > 128) { + Playback.toast("Discord presence title must be between 2 and 128 characters", "error"); + return; + } + page.discordNameInput = value; + page.setSetting("discord_presence_name", value) + .then(() => Playback.toast("Discord now shows “Listening to " + value + "”", "success")); + } + function setAutostart(on) { + var prev = page.settings.autostart; + page.applyLocal("autostart", on ? "true" : "false"); + Daemon.call("set_setting", { key: "autostart", value: on ? "true" : "false" }) + .catch((e) => { page.applyLocal("autostart", prev || "false"); Playback.toast((e && e.message) ? e.message : "Could not change autostart", "error"); }); + } + function setQuality(q) { + page.setSetting("quality", q) + // URLs are keyed by video only, so they must be dropped; the playback identity + // survives a quality change untouched. + .then(() => Daemon.call("clear_caches", { rotate: false }).catch(() => {})) + .then(() => Playback.toast("Audio quality updated", "success")); + } + function saveProxy() { + var value = page.proxyInput.trim(); + page.setSetting("proxy", value) + .then(() => { page.proxyInput = value; Playback.toast("Proxy saved — restart to apply", "success"); }); + } + function clearCaches() { + page.clearing = true; + Daemon.call("clear_caches") + .then((res) => { + page.clearing = false; + // The daemon force-clears every stream cache and re-bootstraps the YouTube + // playback identity; the second half needs the network to work. + if (res && res.visitorDataRefreshed === false) + Playback.toast("Caches cleared — offline, playback ID will retry on next play", "info"); + else + Playback.toast("Playback caches cleared", "success"); + }) + .catch((e) => { page.clearing = false; Playback.toast((e && e.message) ? e.message : String(e), "error"); }); + } + + // --- software updates ------------------------------------------------------------------ + function checkUpdates() { + if (page.updateStatus === "checking" || page.updateStatus === "installing") return; + page.updateStatus = "checking"; + page.updateError = ""; + page.showReleaseNotes = false; + Daemon.call("check_for_updates") + .then((info) => { page.updateInfo = info || null; page.updateStatus = "idle"; }) + .catch((e) => { page.updateError = (e && e.message) ? e.message : String(e); page.updateStatus = "error"; }); + } + function installUpdate() { + if (page.updateStatus === "installing") return; + var info = page.updateInfo; + if (!info || !info.latestVersion || !info.available || !info.canInstall) return; + var version = info.latestVersion; + page.updateStatus = "installing"; + page.updateError = ""; + Daemon.call("install_update", { version: version }) + .then((res) => { + page.installedVersion = (res && res.version) ? res.version : version; + page.updateStatus = "installed"; + page.restartIntoUpdate(); + }) + .catch((e) => { page.updateError = (e && e.message) ? e.message : String(e); page.updateStatus = "error"; }); + } + // The package is on disk, but this client and the daemon are still running the old code. + // Finish like a normal app does: restart itself. A detached helper waits for the old + // daemon to release its lock file — the daemon's exit is the full teardown (download + // reaping included), so waiting on the lock beats racing it with a timer — then runs the + // launcher, which socket-activates the new daemon and opens the new client. Meanwhile we + // ask the daemon to quit and close this window. If the helper somehow fails, the app + // stays open showing the manual "quit and reopen" guidance as the fallback. + function restartIntoUpdate() { + var lock = (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/ryotunes/ryotunesd.sock.lock"; + Quickshell.execDetached([ + "sh", "-c", + "flock -w 60 \"$1\" -c true 2>/dev/null; exec ryotunes", + "ryotunes-update-restart", lock + ]); + Daemon.call("quit").catch(() => {}); + Qt.quit(); + } + function updateMessage() { + if (page.updateStatus === "checking") return "Checking for a new version…"; + if (page.updateStatus === "installing") return "Downloading and verifying the update, then asking for administrator approval. Keep Ryotunes open until it finishes."; + if (page.updateStatus === "installed") return "Ryotunes " + page.installedVersion + " installed — restarting Ryotunes to finish. If the window is still here, quit it and open it again; the background service reloads only on a full restart."; + if (page.updateStatus === "error") return "Update failed: " + page.updateError; + var info = page.updateInfo; + if (!info) return ""; + if (info.latestVersion === null || info.latestVersion === undefined) return "No release has been published yet."; + if (info.available) { + var msg = "Version " + info.latestVersion + " is available."; + if (!info.canInstall && info.unsupportedReason) msg += " " + info.unsupportedReason; + return msg; + } + return "You're on the latest version."; + } + function openUrl(url) { Quickshell.execDetached(["xdg-open", url]); } + + function clientDisabled(name) { + return (page.settings.disabled_stream_clients || "").split(",").map((s) => s.trim()).filter(Boolean).indexOf(name) >= 0; + } + function toggleClient(name) { + var set = (page.settings.disabled_stream_clients || "").split(",").map((s) => s.trim()).filter(Boolean); + var i = set.indexOf(name); + if (i >= 0) set.splice(i, 1); else set.push(name); + page.setSetting("disabled_stream_clients", set.join(",")); + } + + // --- account --------------------------------------------------------------------------- + function loadIdentities() { + Daemon.call("get_account_identities") + .then((rows) => { page.identities = rows || []; }) + .catch(() => { page.identities = []; }); + } + function switchAccount(key) { + Daemon.call("switch_account", { selectionKey: key }) + .then(() => { page.loadIdentities(); Playback.toast("Account switched", "success"); }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not switch account", "error")); + } + + // --- local folders --------------------------------------------------------------------- + function scanFolders() { + page.foldersLoaded = true; + Daemon.call("get_local_library") + .then((l) => { page.folders = (l && l.folders) ? l.folders : []; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not scan local music", "error")); + } + function addFolder(path) { + if (!path) return; + Daemon.call("add_local_folder", { path: path }) + .then((l) => { if (l && l.folders) page.folders = l.folders; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not add folder", "error")); + } + function removeFolder(path) { + Daemon.call("remove_local_folder", { path: path }) + .then((l) => { page.folders = (l && l.folders) ? l.folders : []; }) + .catch((e) => Playback.toast((e && e.message) ? e.message : "Could not remove folder", "error")); + } + + // --- playlist transfer ----------------------------------------------------------------- + function doImport(path) { + page.importing = true; + Daemon.call("import_playlist_file", { path: path }) + .then((transfer) => { + if (!transfer || !transfer.items || !transfer.items.length) { + page.importing = false; + Playback.toast("That playlist file has no tracks.", "error"); + return; + } + return Daemon.call("create_playlist", { title: transfer.title }).then((id) => { + var chain = Promise.resolve(); + var added = 0; + for (var i = 0; i < transfer.items.length; i++) { + (function (song) { + chain = chain.then(() => Daemon.call("add_to_playlist", { playlistId: id, videoId: song.video_id }) + .then((ok) => { if (ok) added++; }) + .catch(() => {})); + })(transfer.items[i]); + } + return chain.then(() => { + page.importing = false; + Playback.toast("Imported " + added + (added === 1 ? " song" : " songs"), "success"); + }); + }); + }) + .catch((e) => { page.importing = false; Playback.toast((e && e.message) ? e.message : "Could not import", "error"); }); + } + function doExport(path) { + var items = (Playback.queue && Playback.queue.items) ? Playback.queue.items : []; + if (!items.length) return; + var title = (Playback.queue && Playback.queue.sourceName) ? Playback.queue.sourceName : "Ryotunes Queue"; + page.exporting = true; + Daemon.call("export_playlist_file", { title: title, items: items, path: path }) + .then(() => { page.exporting = false; Playback.toast("Queue exported", "success"); }) + .catch((e) => { page.exporting = false; Playback.toast((e && e.message) ? e.message : "Could not export", "error"); }); + } + + function discordLabel(s) { + return s === "connected" ? "Connected" + : s === "connecting" ? "Connecting…" + : s === "unavailable" ? "Discord not running / unavailable" + : "Disabled"; + } + + // The client version file shipped beside the QML. blockLoading makes it ready on open; watching it + // means a package upgrade updates the APP row live while the daemon row stays on its handshake + // version until Ryotunes is fully restarted. + FileView { + id: versionFile + path: Quickshell.shellDir + "/version" + blockLoading: true + watchChanges: true + printErrors: false + onLoaded: page.appVersion = (versionFile.text() || "").trim() + onFileChanged: reload() + } + + // --- pickers (zenity; the daemon expects an explicit path) ------------------------------ + Process { + id: folderPicker + command: ["zenity", "--file-selection", "--directory", "--title=Choose a music folder"] + stdout: StdioCollector { + id: folderOut + onStreamFinished: { var p = folderOut.text.trim(); if (p) page.addFolder(p); } + } + } + Process { + id: importPicker + command: ["zenity", "--file-selection", "--title=Import a playlist file"] + stdout: StdioCollector { + id: importOut + onStreamFinished: { var p = importOut.text.trim(); if (p) page.doImport(p); } + } + } + Process { + id: exportPicker + command: ["zenity", "--file-selection", "--save", "--confirm-overwrite", "--title=Export the queue", "--filename=ryotunes-queue.json"] + stdout: StdioCollector { + id: exportOut + onStreamFinished: { var p = exportOut.text.trim(); if (p) page.doExport(p); } + } + } + + // A two-column on/off setting row: label + description in a fillWidth column, the shared Toggle + // pinned to the right. Unidirectional like the component — renders `value`, emits `flipped`, the + // host writes the daemon setting and the binding feeds the new value back. + component ToggleRow: RowLayout { + id: toggleRow + property string title: "" + property string desc: "" + property string note: "" + property bool value: false + signal flipped(bool on) + + Layout.fillWidth: true + spacing: Style.sp(4) + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: toggleRow.title + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + visible: toggleRow.desc !== "" + text: toggleRow.desc + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + Text { + visible: toggleRow.note !== "" + text: toggleRow.note + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + } + Toggle { + Layout.alignment: Qt.AlignVCenter + checked: toggleRow.value + onToggled: (v) => toggleRow.flipped(v) + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // --- section rail ------------------------------------------------------------------- + // Frosted over the App backdrop: transparent so the paper@0.88 + bloom read through; only the + // right hairline and the active bone plate carry weight. + Rectangle { + Layout.preferredWidth: Style.sp(46) + Layout.fillHeight: true + color: "transparent" + Hairline { anchors.right: parent.right; width: 1; height: parent.height } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.sp(3) + spacing: Style.sp(1) + + ColumnLayout { + Layout.fillWidth: true + Layout.bottomMargin: Style.sp(2) + spacing: 1 + Text { + text: "力 // SETTINGS" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.DemiBold + font.letterSpacing: 1 + } + Text { + text: "RYOTUNES // 設定" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.letterSpacing: 1 + } + } + + Repeater { + model: page.sections + delegate: Rectangle { + id: railItem + required property var modelData + readonly property bool current: page.section === railItem.modelData.k + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: railItem.current ? Tokens.bone : railHover.hovered ? Tokens.tint10 : "transparent" + + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: railItem.modelData.l + " settings" + Accessible.onPressAction: page.selectSection(railItem.modelData.k) + Keys.onReturnPressed: page.selectSection(railItem.modelData.k) + Keys.onSpacePressed: page.selectSection(railItem.modelData.k) + border.width: activeFocus ? 2 : 0 + border.color: Tokens.ink + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2.5) + anchors.rightMargin: Style.sp(2.5) + spacing: Style.sp(2) + Text { + Layout.fillWidth: true + text: (railItem.current ? "// " : "") + railItem.modelData.l + color: railItem.current ? Tokens.inkOnBone : Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.md + font.weight: Font.Medium + elide: Text.ElideRight + } + Text { + text: railItem.modelData.jp + color: railItem.current ? Tokens.inkOnBone : Tokens.inkFaint + opacity: railItem.current ? 0.8 : 0.55 + font.family: Tokens.jp + font.pixelSize: Style.fs.sm + } + } + HoverHandler { id: railHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: page.selectSection(railItem.modelData.k) + } + } + } + Item { Layout.fillHeight: true } + } + } + + // --- section body ------------------------------------------------------------------- + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Text { + anchors.centerIn: parent + visible: !page.loaded + text: "Loading…" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + + Flickable { + id: scroll + anchors.fill: parent + visible: page.loaded + clip: true + contentWidth: width + boundsBehavior: Flickable.StopAtBounds + contentHeight: { + var h = 0; + if (page.section === "general") h = generalCol.implicitHeight; + else if (page.section === "playback") h = playbackCol.implicitHeight; + else if (page.section === "data") h = dataCol.implicitHeight; + else if (page.section === "downloads") h = downloadSettingsLoader.item ? downloadSettingsLoader.item.implicitHeight : 0; + else if (page.section === "account") h = accountCol.implicitHeight; + else if (page.section === "local") h = localCol.implicitHeight; + else if (page.section === "playlists") h = playlistsCol.implicitHeight; + else if (page.section === "diagnostics") h = diagnosticsLoader.item ? diagnosticsLoader.item.implicitHeight : 0; + else h = aboutCol.implicitHeight; + return h + Style.sp(16); + } + + // ─────────────────────────── GENERAL ─────────────────────────── + ColumnLayout { + id: generalCol + visible: page.section === "general" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + // Appearance ------------------------------------------------------------ + SectionHeading { Layout.fillWidth: true; title: "Appearance"; mark: "表示" } + + // Skin — Prefs.skin picks the palette source (see Skin.qml); clicking a card + // writes it and saves, and Skin re-applies through Tokens so the chrome repaints. + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { Layout.fillWidth: true; text: "Skin"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "The palette, type, motion and decor Ryotunes wears. System follows your Ryoku desktop; install more from RyoStore, or drop a folder in ~/.config/ryotunes/skins to add your own." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + + Flow { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + spacing: Style.sp(3) + + SkinCard { + title: "System" + author: "Follow the Ryoku desktop" + swatches: [Tokens.paper, Tokens.paperLift, Tokens.ink, Tokens.sun, Tokens.bone] + active: Skin.followSystem + onClicked: page.chooseSkin("system") + } + Repeater { + model: Skin.all + delegate: SkinCard { + required property var modelData + title: modelData.name + author: modelData.author !== "" ? modelData.author : "unknown" + swatches: page.skinSwatches(modelData) + badge: page.skinBadge(modelData) + active: !Skin.followSystem && Skin.id === modelData.id + onClicked: page.chooseSkin(modelData.id) + } + } + } + + // Status: the last load/parse problem in the accent, or the fell-back mode note. + Text { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: Skin.error !== "" + text: Skin.error + color: Style.accent; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: Skin.error === "" && Skin.modeMissing + text: "This skin has no " + Skin.mode + " mode; showing its " + (Skin.mode === "dark" ? "light" : "dark") + "." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + + Flow { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + spacing: Style.sp(2) + // RyoStore's `ryotunes-skins` category, when the store is on this box. + Btn { text: "Get more skins"; icon: "download"; primary: true; visible: Skin.storeAvailable; onClicked: Quickshell.execDetached(["ryostore", "open", "ryotunes-skins"]) } + Btn { text: "Open skins folder"; icon: "library"; onClicked: Quickshell.execDetached(["xdg-open", Skin.userDir]) } + Btn { text: "New skin from current"; icon: "add"; onClicked: page.forkOpen = !page.forkOpen } + Btn { text: "Reload"; icon: "repeat"; onClicked: Skin.reload() } + Btn { text: "Skins guide"; icon: "link"; onClicked: Quickshell.execDetached(["xdg-open", "https://github.com/ryoku-dev/ryotunes/blob/main/docs/SKINS.md"]) } + } + + // Fork the painted palette into a new user skin — kebab-cased id, opened to edit. + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: page.forkOpen + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(80) + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: forkField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: forkField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: page.forkName + onTextChanged: page.forkName = text + onAccepted: page.createSkin() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: forkField.text.length === 0 + text: "my-skin-name" + color: Tokens.inkFaint + font: forkField.font + } + } + } + Btn { text: "Create"; primary: true; onClicked: page.createSkin() } + } + } + + // Theme → Prefs.themeMode (+ save); Skin pins Tokens to the mode, so the chrome re-themes. + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.sp(2) + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { Layout.fillWidth: true; text: "Theme"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "Follow the desktop automatically, or pin Ryotunes to the skin's light or dark mode." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + RowLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(2) + Repeater { + model: [ { m: "system", l: "System" }, { m: "light", l: "Light" }, { m: "dark", l: "Dark" } ] + delegate: Chip { + required property var modelData + text: modelData.l + active: Prefs.themeMode === modelData.m + onClicked: { Prefs.themeMode = modelData.m; Prefs.save(); } + } + } + } + } + + // Decor → Prefs.decor (+ save). "skin" follows the skin's own level; rich/calm override it. + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { Layout.fillWidth: true; text: "Decor"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "Skin default keeps the skin's own level; Rich layers grain, register crosses and kana seals over the paper; Calm keeps it plain." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + RowLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(2) + Repeater { + model: [ { m: "skin", l: "Skin default" }, { m: "rich", l: "Rich" }, { m: "calm", l: "Calm" } ] + delegate: Chip { + required property var modelData + text: modelData.l + active: Prefs.decor === modelData.m + onClicked: { Prefs.decor = modelData.m; Prefs.save(); } + } + } + } + } + + // Session --------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Session"; mark: "全般" } + + ToggleRow { + title: "Watch history" + desc: (Playback.auth && Playback.auth.signedIn) ? "Register completed plays in your YouTube Music history." : "Sign in to register completed plays in your YouTube Music history." + value: page.settings.enable_history !== "false" + onFlipped: (on) => page.setSetting("enable_history", on ? "true" : "false") + } + ToggleRow { + title: "Close to tray" + desc: "Closing the window keeps music playing in the background." + value: page.settings.close_to_tray !== "false" + onFlipped: (on) => page.setSetting("close_to_tray", on ? "true" : "false") + } + ToggleRow { + title: "Low resource mode" + desc: "Disable speculative stream warming and reduce automatic Home/network work and decorative motion." + value: page.settings.low_resource_mode === "true" + onFlipped: (on) => page.setSetting("low_resource_mode", on ? "true" : "false") + } + ToggleRow { + title: "Start on login" + desc: "Launch Ryotunes automatically when you log in." + value: page.settings.autostart === "true" + onFlipped: (on) => page.setAutostart(on) + } + + // Interface scale — a wide chip set, stacked under the label. + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "Interface scale"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { + Layout.fillWidth: true + text: "Preferred renderer scale, persisted for every Ryotunes client on this account." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Flow { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Repeater { + model: page.uiScales + delegate: Chip { + required property var modelData + text: modelData + "%" + active: Number(page.settings.ui_scale || "110") === modelData + onClicked: page.setSetting("ui_scale", String(modelData)) + } + } + } + } + + // Discord --------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Discord"; mark: "接続" } + + ToggleRow { + title: "Discord rich presence" + desc: "Show what you're listening to on your Discord profile through the local Discord client." + note: "Status: " + page.discordLabel(page.discordStatus) + value: page.settings.discord_rpc === "true" + onFlipped: (on) => page.setDiscord(on) + } + + // Discord presence title — a text field, stacked. + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "Discord presence title"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { + Layout.fillWidth: true + text: "The text Discord renders as “Listening to …”." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.topMargin: Style.sp(1) + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { + Layout.preferredWidth: Style.sp(70) + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: discordField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: discordField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + maximumLength: 128 + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: page.discordNameInput + onTextChanged: page.discordNameInput = text + onAccepted: page.saveDiscordName() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: discordField.text.length === 0 + text: "Ryotunes" + color: Tokens.inkFaint + font: discordField.font + } + } + } + Pill { label: "Save"; enabled: page.discordNameInput.trim().length > 0; onClicked: page.saveDiscordName() } + Pill { label: "Reset"; enabled: page.discordNameInput !== "Ryotunes"; onClicked: { page.discordNameInput = "Ryotunes"; page.saveDiscordName(); } } + } + Text { + text: "Preview: Listening to " + (page.discordNameInput.trim() || "Ryotunes") + color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.xs + } + } + } + + // ─────────────────────────── PLAYBACK ─────────────────────────── + ColumnLayout { + id: playbackCol + visible: page.section === "playback" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + // Sound — the Ryotunes-only effect chain; opens the Sound dialog. + SectionHeading { Layout.fillWidth: true; title: "Sound"; mark: "音響" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { Layout.fillWidth: true; text: "Sound"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "Shape playback with tempo, pitch, reverb, bass and stereo width — and one-tap presets like Slowed + Reverb." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + Btn { + Layout.alignment: Qt.AlignVCenter + text: "Open" + icon: "sound" + onClicked: Playback.soundRequested() + } + } + + // Engine ---------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Engine"; mark: "再生" } + + // Audio quality + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { Layout.fillWidth: true; text: "Audio quality"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "Preferred stream quality when resolving a track. Changing it clears cached URLs." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + RowLayout { + Layout.alignment: Qt.AlignVCenter + spacing: Style.sp(2) + Repeater { + model: page.qualities + delegate: Chip { + required property var modelData + text: modelData.l + active: (page.settings.quality || "HIGH") === modelData.id + onClicked: page.setQuality(modelData.id) + } + } + } + } + + ToggleRow { + title: "Autoplay" + desc: "Keep the music going with similar songs when your queue ends." + value: page.settings.autoplay !== "false" + onFlipped: (on) => page.setSetting("autoplay", on ? "true" : "false") + } + ToggleRow { + title: "Prevent duplicate tracks in queue" + desc: "Adding a track already queued moves it instead of adding a second copy." + value: page.settings.prevent_duplicates === "true" + onFlipped: (on) => page.setSetting("prevent_duplicates", on ? "true" : "false") + } + ToggleRow { + title: "Word-by-word lyrics" + desc: "Ask lyrics-api.boidu.dev first for per-word timings. Turning this off keeps your listening off that service; line-by-line lyrics still work." + value: page.settings.lyrics_boidu !== "false" + onFlipped: (on) => page.setSetting("lyrics_boidu", on ? "true" : "false") + } + + // Stream clients -------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Stream clients"; mark: "詳細" } + Text { + Layout.fillWidth: true + text: "Advanced — turn a client off to skip it when resolving streams." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Repeater { + model: page.clients + delegate: RowLayout { + id: clientRow + required property var modelData + Layout.fillWidth: true + spacing: Style.sp(4) + Text { + Layout.fillWidth: true + text: clientRow.modelData + color: Tokens.inkDim + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + } + Toggle { + Layout.alignment: Qt.AlignVCenter + checked: !page.clientDisabled(clientRow.modelData) + onToggled: () => page.toggleClient(clientRow.modelData) + } + } + } + } + + Loader { + id: downloadSettingsLoader + active: page.section === "downloads" + visible: active + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + source: "DownloadSettings.qml" + } + + // ─────────────────────────── DATA ─────────────────────────── + ColumnLayout { + id: dataCol + visible: page.section === "data" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + // Network --------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; title: "Network"; mark: "経路" } + + // Proxy — a text field, stacked. + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { text: "Proxy"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { + Layout.fillWidth: true + text: "HTTP or HTTPS proxy for all YouTube traffic. Takes effect on restart." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.topMargin: Style.sp(1) + Layout.fillWidth: true + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + Layout.maximumWidth: Style.sp(120) + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: proxyField.activeFocus ? Tokens.lineStrong : Tokens.line + TextInput { + id: proxyField + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + text: page.proxyInput + onTextChanged: page.proxyInput = text + onAccepted: page.saveProxy() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: proxyField.text.length === 0 + text: "http://host:port (blank = none)" + color: Tokens.inkFaint + font: proxyField.font + } + } + } + Pill { label: "Save"; onClicked: page.saveProxy() } + } + } + + // Storage --------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Storage"; mark: "保存" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { Layout.fillWidth: true; text: "Cache"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium; wrapMode: Text.WordWrap } + Text { + Layout.fillWidth: true + text: "Clear cached stream URLs and downloaded audio bytes, and reset the YouTube playback identity. Fixes tracks that fail with “YouTube rejected the stream link”." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + } + Pill { + Layout.alignment: Qt.AlignVCenter + label: page.clearing ? "Clearing…" : "Force clear caches" + icon: "close" + enabled: !page.clearing + onClicked: page.clearCaches() + } + } + } + + // ─────────────────────────── ACCOUNT ─────────────────────────── + ColumnLayout { + id: accountCol + visible: page.section === "account" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + SectionHeading { Layout.fillWidth: true; title: "Account"; mark: "鍵" } + + // current identity + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Artwork { + visible: !!(Playback.auth && Playback.auth.avatar) + url: (Playback.auth && Playback.auth.avatar) ? Playback.auth.avatar : "" + px: Style.sp(12) + round: true + placeholderIcon: "account" + } + Icon { + visible: !(Playback.auth && Playback.auth.avatar) + name: "account"; size: Style.fs.hero; color: Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + text: (Playback.auth && Playback.auth.signedIn && Playback.auth.name) ? Playback.auth.name : "Not signed in" + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.lg; font.weight: Font.DemiBold + elide: Text.ElideRight; Layout.fillWidth: true + } + Text { + text: (Playback.auth && Playback.auth.signedIn) ? "YouTube Music" : "Sign in to sync your library" + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm + } + } + Pill { + label: (Playback.auth && Playback.auth.signedIn) ? "Sign out" : "Sign in with Google" + icon: (Playback.auth && Playback.auth.signedIn) ? "close" : "account" + primary: !(Playback.auth && Playback.auth.signedIn) + onClicked: { + var out = !!(Playback.auth && Playback.auth.signedIn); + Daemon.call(out ? "sign_out" : "sign_in").catch((e) => Playback.toast((e && e.message) ? e.message : String(e), "error")); + } + } + } + + // ── Spotify ───────────────────────────────────────────────────────── + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Spotify"; mark: "緑" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Icon { + name: "spotify" + size: Style.fs.hero + color: (Playback.spotify && Playback.spotify.signedIn) ? Style.providerColor : Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + text: (Playback.spotify && Playback.spotify.signedIn && Playback.spotify.name) ? Playback.spotify.name : "Not signed in" + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.lg; font.weight: Font.DemiBold + elide: Text.ElideRight; Layout.fillWidth: true + } + Text { + readonly property var sp: Playback.spotify || ({}) + text: sp.signedIn ? "Connected \u00b7 Premium" + : (sp.error ? sp.error + : (sp.flow === "browser" ? "Waiting for your browser\u2026" + : "Not connected \u00b7 Premium is required for playback.")) + color: sp.error ? Style.accent : Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap; Layout.fillWidth: true + } + } + Pill { + label: (Playback.spotify && Playback.spotify.signedIn) ? "Sign out" : "Sign in to Spotify" + icon: (Playback.spotify && Playback.spotify.signedIn) ? "close" : "spotify" + primary: !(Playback.spotify && Playback.spotify.signedIn) + onClicked: { + var out = !!(Playback.spotify && Playback.spotify.signedIn); + if (out) Playback.spotifySignOut().catch((e) => Playback.toast((e && e.message) ? e.message : String(e), "error")); + else Playback.spotifySignIn().catch((e) => Playback.toast((e && e.message) ? e.message : String(e), "error")); + } + } + } + + // ── SoundCloud ────────────────────────────────────────────────────── + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "SoundCloud"; mark: "雲" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Icon { + name: "soundcloud" + size: Style.fs.hero + color: Playback.provider === "soundcloud" ? Style.providerColors.soundcloud : Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + readonly property var sc: Playback.soundcloud || ({}) + text: (sc.signedIn && sc.name) ? sc.name : (sc.signedIn ? "Signed in" : "Listening as a guest") + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.lg; font.weight: Font.DemiBold + elide: Text.ElideRight; Layout.fillWidth: true + } + Text { + readonly property var sc: Playback.soundcloud || ({}) + text: sc.signedIn ? "Connected \u00b7 your playlists and likes join Home" + : (sc.error ? sc.error + : "No account needed \u00b7 SoundCloud's public catalogue plays for everyone.") + color: (sc.error && !sc.signedIn) ? Style.accent : Tokens.inkMuted + font.family: Style.fontUi; font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap; Layout.fillWidth: true + } + } + Pill { + readonly property var sc: Playback.soundcloud || ({}) + label: sc.signedIn ? "Sign out" : "Sign in to SoundCloud" + icon: sc.signedIn ? "close" : "soundcloud" + primary: !sc.signedIn + onClicked: { + var out = !!(Playback.soundcloud && Playback.soundcloud.signedIn); + var call = out ? Playback.soundcloudSignOut() : Playback.soundcloudSignIn(); + call.catch((e) => Playback.toast((e && e.message) ? e.message : String(e), "error")); + } + } + } + + // switch account + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Channels"; mark: "選択"; visible: page.identities.length > 0 } + ColumnLayout { + Layout.fillWidth: true + visible: page.identities.length > 0 + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: "Pick which YouTube channel or brand account this session acts as." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Repeater { + model: page.identities + delegate: RowLayout { + id: idRow + required property var modelData + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Artwork { + url: idRow.modelData.thumbnail || "" + px: Style.sp(8) + round: true + placeholderIcon: "account" + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: idRow.modelData.name + (idRow.modelData.selected ? " · CURRENT" : "") + color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; elide: Text.ElideRight + } + Text { + visible: !!idRow.modelData.handle + text: idRow.modelData.handle || "" + color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs + } + } + Pill { + label: "Use" + enabled: !idRow.modelData.selected + onClicked: page.switchAccount(idRow.modelData.selectionKey) + } + } + } + } + Text { + visible: page.identities.length === 0 + text: (Playback.auth && Playback.auth.signedIn) ? "This account has a single channel." : "Sign in to see the channels on this account." + color: Tokens.inkFaint; font.family: Style.fontUi; font.pixelSize: Style.fs.sm + } + } + + // ─────────────────────────── LOCAL MUSIC ─────────────────────────── + ColumnLayout { + id: localCol + visible: page.section === "local" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + SectionHeading { Layout.fillWidth: true; title: "Watched folders"; mark: "音源" } + Text { + Layout.fillWidth: true + text: "Folders Ryotunes watches for files on disk. The daemon rescans them on demand." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + + RowLayout { + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Pill { label: "Add folder"; icon: "add"; primary: true; onClicked: folderPicker.running = true } + Pill { label: "Rescan"; icon: "on-repeat"; onClicked: page.scanFolders() } + Item { Layout.fillWidth: true } + } + + Text { + visible: !page.folders.length + Layout.topMargin: Style.sp(1) + text: "No folders yet. Add the one your music sits in." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm + } + + Repeater { + model: page.folders + delegate: RowLayout { + id: folderRow + required property var modelData + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Rectangle { + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2.5) + anchors.rightMargin: Style.sp(1) + spacing: Style.sp(2) + Icon { name: "music"; size: Style.fs.sm; color: Tokens.inkMuted } + Text { + Layout.fillWidth: true + text: folderRow.modelData + color: Tokens.inkDim + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + elide: Text.ElideMiddle + } + IconButton { + icon: "close" + iconSize: Style.fs.sm + diameter: Style.sp(7) + onClicked: page.removeFolder(folderRow.modelData) + } + } + } + } + } + } + + // ─────────────────────────── PLAYLISTS ─────────────────────────── + ColumnLayout { + id: playlistsCol + visible: page.section === "playlists" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(3) + + // Import ---------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; title: "Import a playlist"; mark: "転送" } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: "Choose a Ryotunes playlist file; its tracks land in a new library playlist. Sign in first." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Pill { + Layout.topMargin: Style.sp(1) + label: page.importing ? "Importing…" : "Import from file" + icon: "add" + enabled: !page.importing && !!(Playback.auth && Playback.auth.signedIn) + onClicked: importPicker.running = true + } + } + + // Export ---------------------------------------------------------------- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Export the current queue"; mark: "出力" } + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(1) + Text { + Layout.fillWidth: true + text: "Write the tracks now in your queue to a portable .json file." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + Pill { + Layout.topMargin: Style.sp(1) + label: page.exporting ? "Exporting…" : "Export to file" + icon: "playlist" + enabled: !page.exporting && !!(Playback.queue && Playback.queue.items && Playback.queue.items.length) + onClicked: exportPicker.running = true + } + } + } + + // ─────────────────────────── DIAGNOSTICS ─────────────────────────── + Loader { + id: diagnosticsLoader + active: page.section === "diagnostics" + visible: active + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + source: "DiagnosticsPage.qml" + } + + + // ─────────────────────────── ABOUT ─────────────────────────── + ColumnLayout { + id: aboutCol + visible: page.section === "about" + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: Style.sp(6); rightMargin: Style.sp(6); topMargin: Style.sp(4) } + spacing: Style.sp(2) + + SectionHeading { Layout.fillWidth: true; title: "About"; mark: "力" } + Text { + Layout.fillWidth: true + text: "A focused Ryoku desktop music instrument: your YouTube Music library, local media, queue, lyrics and playback engine in one paper-and-ink surface." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.md; wrapMode: Text.WordWrap + } + GridLayout { + Layout.topMargin: Style.sp(2) + columns: 2 + columnSpacing: Style.sp(6) + rowSpacing: Style.sp(1) + Text { text: "APP"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Text { text: page.appVersion || "—"; color: Tokens.inkDim; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; font.weight: Font.Medium } + Text { text: "DAEMON"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Text { text: Daemon.daemonVersion || "—"; color: Tokens.inkDim; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; font.weight: Font.Medium } + Text { text: "ENGINE"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Text { text: "RUST + MPV"; color: Tokens.inkDim; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; font.weight: Font.Medium } + Text { text: "CLIENT"; color: Tokens.inkFaint; font.family: Style.fontMono; font.pixelSize: Style.fs.xs } + Text { text: "QUICKSHELL / QML"; color: Tokens.inkDim; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; font.weight: Font.Medium } + } + + // --- software updates --- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Software updates"; mark: "新" } + Text { + Layout.fillWidth: true + text: "You're on " + (page.appVersion || Daemon.daemonVersion || "this build") + ". Ryotunes checks GitHub only when you ask — nothing runs in the background." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Btn { + text: page.updateStatus === "checking" ? "Checking…" : "Check for new version" + icon: "repeat" + enabled: page.updateStatus !== "checking" && page.updateStatus !== "installing" && page.updateStatus !== "installed" + onClicked: page.checkUpdates() + } + Btn { + text: "View changelog" + icon: "link" + onClicked: page.openUrl((page.updateInfo && page.updateInfo.releaseUrl) ? page.updateInfo.releaseUrl : "https://github.com/ryoku-dev/ryotunes/releases") + } + Btn { + visible: !!(page.updateInfo && page.updateInfo.available && page.updateInfo.latestVersion && page.updateInfo.canInstall) && page.updateStatus !== "installed" + primary: true + text: page.updateStatus === "installing" ? "Installing…" : ("Update to " + (page.updateInfo ? page.updateInfo.latestVersion : "")) + icon: "download" + enabled: page.updateStatus !== "installing" && page.updateStatus !== "checking" + onClicked: page.installUpdate() + } + } + Text { + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + visible: text.length > 0 + text: page.updateMessage() + color: page.updateStatus === "error" ? Tokens.alert + : (page.updateStatus === "idle" && page.updateInfo && page.updateInfo.available) ? Tokens.sun + : Tokens.inkMuted + font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + textFormat: Text.PlainText + Accessible.role: Accessible.StaticText + Accessible.name: text + } + Btn { + visible: !!(page.updateInfo && page.updateInfo.notes) + Layout.topMargin: Style.sp(1) + text: page.showReleaseNotes ? "Hide release notes" : "Show release notes" + onClicked: page.showReleaseNotes = !page.showReleaseNotes + } + Rectangle { + visible: page.showReleaseNotes && !!(page.updateInfo && page.updateInfo.notes) + Layout.fillWidth: true + Layout.topMargin: Style.sp(1) + Layout.preferredHeight: Math.min(notesText.implicitHeight + Style.sp(4), Style.sp(60)) + color: "transparent" + border.width: 1 + border.color: Tokens.line + radius: Style.radius + Flickable { + anchors.fill: parent + anchors.margins: Style.sp(2) + clip: true + contentWidth: width + contentHeight: notesText.implicitHeight + Text { + id: notesText + width: parent.width + text: (page.updateInfo && page.updateInfo.notes) ? page.updateInfo.notes : "" + color: Tokens.inkMuted; font.family: Style.fontMono; font.pixelSize: Style.fs.xs; wrapMode: Text.WordWrap; textFormat: Text.PlainText + } + } + } + + // --- made by --- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Made by"; mark: "人" } + Text { + Layout.fillWidth: true + text: "Ryotunes is built by two developers. Open their GitHub profiles:" + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Btn { text: "ashmitvoid"; icon: "link"; onClicked: page.openUrl("https://github.com/ashmitvoid") } + Btn { text: "neur0map"; icon: "link"; onClicked: page.openUrl("https://github.com/neur0map") } + } + + // --- open source --- + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "Open source"; mark: "源" } + Text { + Layout.fillWidth: true + text: "Ryotunes is a fork of LiMusic, released under the GNU General Public License v3.0 or later." + color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap + } + RowLayout { + Layout.topMargin: Style.sp(1) + spacing: Style.sp(2) + Btn { text: "LiMusic upstream"; icon: "link"; onClicked: page.openUrl("https://github.com/SimoHypers/limusic") } + Btn { text: "GPL-3.0-or-later"; icon: "link"; onClicked: page.openUrl("https://www.gnu.org/licenses/gpl-3.0.html") } + } + } + } + } + } +} diff --git a/client/qmldir b/client/qmldir new file mode 100644 index 0000000..150162e --- /dev/null +++ b/client/qmldir @@ -0,0 +1,11 @@ +singleton Daemon 1.0 Daemon.qml +singleton Playback 1.0 Playback.qml +singleton Downloads 1.0 Downloads.qml +singleton Router 1.0 Router.qml +singleton Style 1.0 Style.qml +singleton Skin 1.0 Skin.qml +singleton Personal 1.0 Personal.qml +singleton Logs 1.0 Logs.qml +singleton Prefs 1.0 Prefs.qml +singleton Spectrum 1.0 Spectrum.qml +App 1.0 App.qml diff --git a/client/shaders/rounded.frag b/client/shaders/rounded.frag new file mode 100644 index 0000000..d7284c0 --- /dev/null +++ b/client/shaders/rounded.frag @@ -0,0 +1,20 @@ +#version 440 +// Rounded-rectangle clip for a single Image layer: one quad, one texture read, no mask source. +// `radius` and `size` are in the layer's pixels; a 1 px feather keeps the edge anti-aliased. +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 size; + float radius; +}; +layout(binding = 1) uniform sampler2D source; + +void main() { + vec2 p = qt_TexCoord0 * size; + vec2 q = abs(p - size * 0.5) - (size * 0.5 - vec2(radius)); + float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; + float a = 1.0 - smoothstep(-1.0, 0.0, d); + fragColor = texture(source, qt_TexCoord0) * a * qt_Opacity; +} diff --git a/client/shaders/rounded.frag.qsb b/client/shaders/rounded.frag.qsb new file mode 100644 index 0000000..c987287 Binary files /dev/null and b/client/shaders/rounded.frag.qsb differ diff --git a/client/shell.qml b/client/shell.qml new file mode 100644 index 0000000..6361e13 --- /dev/null +++ b/client/shell.qml @@ -0,0 +1,188 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Ryoku.Ui.Singletons +import "mini" +import "components" + +ShellRoot { + id: shellRoot + + // Ask for the version handshake, the event stream and its opening snapshot as soon as the + // config is up. subscribeAll() is idempotent and re-subscribes on every reconnect, so a single + // call here covers a daemon that is already up, one that starts later, and one that restarts. + Component.onCompleted: { + // Package upgrades replace QML files non-atomically. Keep this running generation + // intact until the user quits and reopens, rather than reloading mid-transaction. + Quickshell.watchFiles = false; + Daemon.subscribeAll(); + // Touch the Logs singleton so its Connections (callFailed capture + reconnect flush) + // exist from the first moment a failure could happen; the flush is a no-op when empty. + Logs.flush(); + Style.applyPrefs(); + } + + // Dev hooks (all optional, unset in normal use): RYOTUNES_WINDOW_TITLE renames the toplevel so + // a test instance can be told apart from the daily one; RYOTUNES_SCREEN pins the layer + // surfaces to an output (a headless test output, for instance); RYOTUNES_CTL opens a control + // socket that takes one command per line: `nav [json]`, `show`, `mini on|off`, + // `np `, `panel on|off`, `decor rich|calm`, `theme system|light|dark`. + readonly property string devTitle: Quickshell.env("RYOTUNES_WINDOW_TITLE") || "" + readonly property string devScreen: Quickshell.env("RYOTUNES_SCREEN") || "" + readonly property string devCtl: Quickshell.env("RYOTUNES_CTL") || "" + readonly property var devScreenObj: { + if (!shellRoot.devScreen) return null; + var list = Quickshell.screens; + for (var i = 0; i < list.length; i++) if (list[i].name === shellRoot.devScreen) return list[i]; + return null; + } + + function ctl(cmd, arg, rest) { + if (cmd === "nav") { + var params = {}; + try { params = rest ? JSON.parse(rest) : {}; } catch (e) { params = {}; } + Router.push(arg, params); + } else if (cmd === "show") shellRoot.present(); + else if (cmd === "mini") appRoot.miniOpen = arg === "on"; + else if (cmd === "np") { if (arg === "off") appRoot.npClose(); else appRoot.npOpenTab(arg); } + else if (cmd === "panel") appRoot.panelOpen = arg === "on"; + else if (cmd === "sidebar") appRoot.sidebarOpen = arg === "on"; + else if (cmd === "decor") { Prefs.decor = arg; Prefs.save(); } + else if (cmd === "theme") { Prefs.themeMode = arg; Prefs.save(); } + else if (cmd === "provider") Playback.setProvider(arg); + else if (cmd === "suggest") appRoot.devSuggest(arg); + } + + SocketServer { + active: shellRoot.devCtl !== "" + path: shellRoot.devCtl + handler: Socket { + parser: SplitParser { + onRead: (line) => { + var parts = line.trim().split(" "); + shellRoot.ctl(parts[0], parts[1] || "", parts.slice(2).join(" ")); + } + } + } + } + + FloatingWindow { + id: win + title: shellRoot.devTitle || "Ryotunes" + color: Tokens.paper + minimumSize: Qt.size(900, 620) + // Open at the Tauri app's daily-driver geometry (about 86% x 82% of the monitor, capped at + // 1600 x 1000) rather than the minimum; the Ryoku rule floats and centres it. + implicitWidth: Math.max(900, Math.min(1600, Math.round((win.screen ? win.screen.width : 1600) * 0.86))) + implicitHeight: Math.max(620, Math.min(1000, Math.round((win.screen ? win.screen.height : 1000) * 0.82))) + + // Honour this monitor's Interface scale through Tokens, the same way the Hub does; the app's + // own sp()/type scale ride on it. + Binding { + target: Tokens + property: "uiScale" + value: Tokens.uiScaleFor(win.screen && win.screen.name ? win.screen.name : "") + } + App { id: appRoot; anchors.fill: parent } + } + + // The mini player: the Tauri widget's compact 724 x 356 geometry (always on top, skip-taskbar, + // bottom-right of the work area, draggable, position remembered), as a layer-shell surface. + // The surface spans the work area (exclusive zone 0 keeps it out from under the bar and dock) + // and is transparent and input-masked everywhere except the widget, which is an item dragged + // inside it: a surface that moves itself under the pointer feeds its own drag deltas back and + // flies off, an item in a fixed surface does not. Opening it hides the main window the way the + // Tauri app hibernated it; the maximize button brings the main window back. + PanelWindow { + id: miniWin + visible: appRoot.miniOpen + screen: shellRoot.devScreenObj + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusiveZone: 0 + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "ryotunes-mini" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region { x: miniBox.x; y: miniBox.y; width: miniBox.width; height: miniBox.height } + + Item { + id: miniBox + width: 724 + height: 356 + ArtAccent {} + // Offsets from the work area's bottom-right corner, clamped so the widget stays on + // screen whatever the monitor. + x: Math.max(0, Math.min(miniWin.width - width, miniWin.width - width - Prefs.miniRight)) + y: Math.max(0, Math.min(miniWin.height - height, miniWin.height - height - Prefs.miniBottom)) + + MiniPlayer { + anchors.fill: parent + active: miniWin.visible + dragTarget: miniBox + dragMaxX: Math.max(0, miniWin.width - miniBox.width) + dragMaxY: Math.max(0, miniWin.height - miniBox.height) + onMaximize: { + appRoot.miniOpen = false; + shellRoot.present(); + } + onDragEnded: { + Prefs.miniRight = Math.round(miniWin.width - miniBox.width - miniBox.x); + Prefs.miniBottom = Math.round(miniWin.height - miniBox.height - miniBox.y); + Prefs.save(); + } + } + } + } + + // Pause-to-quit: a minute into a pause with no window on screen (closed to tray, or the + // mini alone), this client exits; the daemon's own idle grace then takes it out too, so a + // paused Ryotunes leaves nothing running. A visible main window keeps it alive - that is the + // user's attention, not idleness - and play, or presenting the window, cancels the timer. + readonly property bool parked: !!Playback.now && Playback.paused && !win.visible + Timer { + id: pauseQuit + interval: 60000 + running: shellRoot.parked + onTriggered: Qt.quit() + } + + Connections { + target: appRoot + function onMiniOpenChanged(): void { + if (appRoot.miniOpen) + win.visible = false; + } + } + + // "Come back" from two directions: the daemon's `show` event (tray Show, a second `ryotunes` / + // `ryotunesd` launch, the desktop keybind) and `qs -p ... ipc call window show`. A closed + // FloatingWindow is hidden, not destroyed, so this process stays subscribed and the daemon's + // show reaches it here rather than spawning a second client. + function present(): void { + appRoot.miniOpen = false; + // After a compositor close (Super+Q) the toplevel is gone but Quickshell leaves + // `visible` at true, so assigning true again is a no-op; drop it first to remap. + win.visible = false; + win.visible = true; + } + Connections { + target: Daemon + function onEvent(name: string, data: var): void { + if (name === "show") + shellRoot.present(); + } + } + IpcHandler { + target: "window" + function show(): void { shellRoot.present(); } + function mini(): void { appRoot.miniOpen = !appRoot.miniOpen; } + // `qs -p … ipc call window ctl " [arg]"`: the same verbs as the dev control socket + // (nav, np, panel, sidebar, decor, theme, provider), for scripts and the desktop. + function ctl(line: string): void { + var parts = String(line).trim().split(" "); + shellRoot.ctl(parts[0], parts[1] || "", parts.slice(2).join(" ")); + } + } +} diff --git a/client/tests/tst_collections.qml b/client/tests/tst_collections.qml new file mode 100644 index 0000000..493810c --- /dev/null +++ b/client/tests/tst_collections.qml @@ -0,0 +1,78 @@ +import QtQuick +import QtTest +import "../lib/collections.js" as Collections + +// groupJobs collapses batch-added download jobs into one card per collection while leaving +// single tracks as flat rows; stats aggregates a card's progress. Both are pure over the +// daemon's job JSON, so this runs under qmltestrunner without Quickshell. +TestCase { + name: "Collections" + + function job(id, status, collection, kind, progress) { + var j = { videoId: id, status: status }; + if (collection !== undefined) + j.collection = collection; + if (kind !== undefined) + j.collectionKind = kind; + if (progress !== undefined) + j.progress = progress; + return j; + } + + // A mixed list: one album (3 jobs), one single, the same album continued after the single + // (a later batch member must join the FIRST card, not open a second one). + function test_groups_collapse_by_label_in_first_position() { + var list = [ + job("a1", "downloading", "Discovery", "playlist", 40), + job("s1", "queued"), + job("a2", "queued", "Discovery", "playlist"), + job("b1", "completed", "Singles Mix", "playlist"), + job("a3", "completed", "Discovery", "playlist") + ]; + var out = Collections.groupJobs(list); + compare(out.length, 3, "2 cards + 1 flat row: the album collapses even across the single"); + verify(!!out[0].group); + compare(out[0].group.label, "Discovery"); + compare(out[0].group.kind, "playlist"); + compare(out[0].group.jobs.length, 3); + verify(!!out[1].job, "the single stays a flat row"); + compare(out[1].job.videoId, "s1"); + compare(out[2].group.label, "Singles Mix"); + } + + function test_empty_and_missing_labels_stay_flat() { + var out = Collections.groupJobs([job("x", "completed"), job("y", "failed", "")]); + compare(out.length, 2); + verify(!!out[0].job && !!out[1].job); + compare(Collections.groupJobs([]).length, 0); + } + + function test_kind_defaults_to_collection() { + var out = Collections.groupJobs([job("z", "queued", "Lo-fi Beats")]); + compare(out[0].group.kind, "collection"); + } + + // Completed and terminal jobs count as full; the downloading one contributes its own + // percent; waiting contributes nothing. + function test_stats_weight_progress_across_statuses() { + var s = Collections.stats([ + job("a", "completed"), + job("b", "downloading", "X", "album", 50), + job("c", "queued", "X", "album"), + job("d", "failed", "X", "album") + ]); + compare(s.done, 1); + compare(s.moving, 1); + compare(s.waiting, 1); + compare(s.failed, 1); + // (100 + 50 + 0 + 100) / 4 + compare(s.percent, 63); + } + + function test_stats_on_empty_is_quiet() { + var s = Collections.stats([]); + compare(s.percent, 0); + compare(s.done, 0); + compare(s.moving, 0); + } +} diff --git a/client/tests/tst_home_feed.qml b/client/tests/tst_home_feed.qml new file mode 100644 index 0000000..8cb2c72 --- /dev/null +++ b/client/tests/tst_home_feed.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtTest +import "../components" + +TestCase { + name: "HomeFeed" + when: windowShown + width: 600 + visible: true + height: 500 + + Component { + id: feedComponent + HomeFeed { + id: feed + width: 600 + height: 500 + property int headerHeight: 1600 + model: 20 + header: Rectangle { width: 600; height: feed.headerHeight; color: "gray" } + delegate: Rectangle { width: 600; height: 200; color: "white" } + } + } + + function makeFeed() { + var feed = createTemporaryObject(feedComponent, this); + verify(feed !== null); + waitForRendering(feed); + tryCompare(feed.headerItem, "height", 1600); + return feed; + } + + function test_first_wheel_does_not_skip_tall_header() { + var feed = makeFeed(); + var top = feed.contentY; + mouseWheel(feed, 300, 200, 0, -120); + tryVerify(function () { return feed.contentY > top; }, 1000, "Wheel must actually scroll"); + tryVerify(function () { return !feed.moving; }); + verify(feed.contentY - top < feed.height, "One wheel tick must not skip the whole Home header"); + } + + function test_releasing_pin_preserves_position() { + var feed = makeFeed(); + var top = feed.contentY; + feed.stickTop = false; + compare(feed.contentY, top, "Disabling the pin must not restore pre-layout contentY=0"); + } + + function test_async_header_growth_stays_at_top_until_interaction() { + var feed = makeFeed(); + feed.headerHeight = 2100; + tryCompare(feed.headerItem, "height", 2100); + tryCompare(feed, "contentY", feed.originY); + feed.stickTop = false; + feed.contentY += 120; + var offset = feed.contentY; + wait(50); + compare(feed.contentY, offset, "The top pin must not reclaim a user-owned scroll position"); + } +} diff --git a/client/tests/tst_personal.qml b/client/tests/tst_personal.qml new file mode 100644 index 0000000..454fa33 --- /dev/null +++ b/client/tests/tst_personal.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtTest +import "../lib/personal.js" as P + +// The personal store's pure reducers, run without Quickshell (Personal.qml imports Daemon). These +// are ports of ui/src/lib/personal.check.ts: the rules that are easy to get wrong — recency that +// refreshes but never reorders, a removal that permanently blocks the suggestion, the Home +// arrangement's stable unranked sort and one-time '@familiar' slotting, and hydrate degrading junk +// to empty instead of throwing. +TestCase { + name: "Personal" + + function item(id) { return { kind: "playlist", id: id, title: id }; } + function song(id, artistId) { + var s = { kind: "song", id: id, title: id.toUpperCase(), subtitle: "Artist " + id, thumbnail: id + ".jpg" }; + if (artistId) s.artistId = artistId; + return s; + } + function ids(list) { return list.map(function (x) { return x.id; }).join(","); } + function keys(list) { return list.map(function (x) { return x.key; }).join(","); } + function secs() { return Array.prototype.slice.call(arguments).map(function (k) { return { key: k }; }); } + + // addPick appends and refreshes recency; touchPick refreshes without reordering. + function test_addpick_and_touchpick() { + var p = P.empty(); + P.addPick(p, item("a"), 100); + P.addPick(p, item("b"), 200); + compare(P.addPick(p, item("a"), 300), false); // a repeat add reports "already there" + compare(p.picks.length, 2); // and does not duplicate + compare(p.picks.filter(function (x) { return x.id === "a"; })[0].lastUsedAt, 300); // but refreshes + P.addPick(p, item("c"), 250); + compare(ids(p.picks), "a,b,c"); // display order is add order + compare(P.touchPick(p, "b", 99999), true); + compare(ids(p.picks), "a,b,c"); // playing a tile does not move it + compare(P.touchPick(p, "nope", 1), false); // a tile not on the grid reports no change + } + + // A seeded suggestion is refusable, and a removal permanently blocks re-suggestion while still + // letting the user add it back by hand. + function test_seed_and_remove_are_permanent() { + var p = P.empty(); + verify(P.seedPick(p, item("onrepeat"))); + verify(!P.seedPick(p, item("onrepeat"))); // already on the grid + P.removePick(p, "onrepeat"); + compare(p.picks.length, 0); + verify(!P.seedPick(p, item("onrepeat"))); // a removed suggestion never comes back + verify(!P.seedPick(p, item("onrepeat"), 9999)); // not on a later visit either + compare(P.addPick(p, item("onrepeat"), 1), true); // the user can still add it manually + compare(ids(p.picks), "onrepeat"); + P.removePick(p, "onrepeat"); + verify(!P.seedPick(p, item("onrepeat"))); // removing again re-arms the dismissal + } + + // recentItems: newest played-from first, capped by n, empty when nothing played. + function test_recent_items() { + var p = P.empty(); + compare(P.recentItems(p).length, 0); + P.noteRecent(p, item("a"), 100); + P.noteRecent(p, item("b"), 300); + P.noteRecent(p, item("c"), 200); + compare(ids(P.recentItems(p)), "b,c,a"); + compare(ids(P.recentItems(p, 2)), "b,c"); + } + + // noteArtist accumulates play counts and updates the name; topArtistIds keeps only channel-keyed + // (UC…) entries, ordered by plays. + function test_note_artist_and_top_ids() { + var p = P.empty(); + P.noteArtist(p, "UCb", "B"); + P.noteArtist(p, "UCb", "B"); + P.noteArtist(p, "UCa", "A"); + P.noteArtist(p, "Some Band", "Some Band"); // name-keyed: no channel to open + compare(p.artists["UCb"].count, 2); + compare(p.artists["UCa"].count, 1); + compare(P.topArtistIds(p).join(","), "UCb,UCa"); // plays desc, name-keyed dropped + compare(P.topArtistIds(p, 1).join(","), "UCb"); + compare(P.topArtistIds(P.empty()).length, 0); + // A later note updates the display name even when it started blank. + P.noteArtist(p, "UCc", ""); + P.noteArtist(p, "UCc", "C"); + compare(p.artists["UCc"].name, "C"); + compare(p.artists["UCc"].count, 2); + } + + // firstArtist takes the lead credit out of a joined artist string. + function test_first_artist() { + compare(P.firstArtist("Daft Punk"), "Daft Punk"); + compare(P.firstArtist("Daft Punk, Pharrell Williams"), "Daft Punk"); + compare(P.firstArtist("The Weeknd & Ariana Grande"), "The Weeknd"); + compare(P.firstArtist("Drake feat. Rihanna"), "Drake"); + } + + // arrangeSections: saved order wins, hidden ones still return, unranked hold their feed order + // (they must not compare as NaN), an empty feed is fine. + function test_arrange_sections() { + var p = P.empty(); + compare(keys(P.arrangeSections(secs("a", "b", "c"), p)), "a,b,c"); + p.home = { order: ["c", "a"], hidden: ["a"], seen: [] }; + compare(keys(P.arrangeSections(secs("a", "b", "c"), p)), "c,a,b"); + compare(keys(P.arrangeSections(secs("z", "y", "c"), p)), "c,z,y"); + compare(keys(P.arrangeSections([], p)), ""); + } + + // hydrate degrades junk to empty rather than throwing, caps pins, drops malformed tiles and + // auto-seeded ones from the old build, and filters junk dismissals. + function test_hydrate_tolerates_junk() { + compare(P.hydrate(null).picks.length, 0); + compare(P.hydrate("nonsense").pins.length, 0); + compare(P.hydrate({ pins: ["a", "b", "c", "d", "e"] }).pins.length, 3); + compare(P.hydrate({ picks: [{ id: "a" }, {}] }).picks.length, 1); + var migrated = P.hydrate({ picks: [{ id: "kept", manual: true }, { id: "seeded", manual: false }, { id: "new" }] }); + compare(migrated.picks.map(function (x) { return x.id; }).join(","), "kept,new"); + compare(P.hydrate({ dismissedSeeds: ["a", 7] }).dismissedSeeds.join(","), "a"); + } + + // The Home arrangement survives a round trip, slots '@familiar' into an order saved before it + // existed exactly once, and degrades a corrupt one instead of throwing. + function test_hydrate_home_arrangement() { + var p = P.empty(); + p.home = { order: ["@recent", "Listen again"], hidden: ["@forgotten"], seen: ["Listen again"] }; + var back = P.hydrate(JSON.parse(JSON.stringify(p))); + compare(back.home.order.join(","), "@recent,@familiar,Listen again"); + compare(back.home.hidden.join(","), "@forgotten"); + compare(back.home.seen.join(","), "Listen again"); + compare(P.hydrate({ home: { order: ["Listen again"], hidden: [] } }).home.order.join(","), "Listen again,@familiar"); + // Slotting happens once, not on every load. + compare(P.hydrate(JSON.parse(JSON.stringify(back))).home.order.join(","), back.home.order.join(",")); + compare(P.hydrate({}).home.order.length, 0); + compare(P.hydrate({ home: { order: [1, "a"], hidden: "nope" } }).home.order.join(","), "a,@familiar"); + } + + // A played song is stored as a full navigable BrowseItem, keyed by video id, and shares the + // recent window with played-from playlists/albums without either shape clobbering the other. + function test_song_recents_shape_and_mix() { + var p = P.empty(); + P.noteRecent(p, song("v1", "UCx"), 100); + P.noteRecent(p, item("PL1"), 200); // a played-from playlist + P.noteRecent(p, song("v2"), 300); + compare(ids(P.recentItems(p)), "v2,PL1,v1"); // newest first, songs and playlists together + var v1 = p.recent["v1"]; + compare(v1.kind, "song"); + compare(v1.title, "V1"); + compare(v1.subtitle, "Artist v1"); + compare(v1.thumbnail, "v1.jpg"); + compare(v1.artistId, "UCx"); + verify(p.recent["v2"].artistId === undefined); // an unlinked-artist song omits it + compare(v1.at, 100); + } + + // Replaying an id refreshes its recency in place (one entry, newest timestamp), never a + // duplicate row. + function test_song_recent_replay_refreshes() { + var p = P.empty(); + P.noteRecent(p, song("v1"), 100); + P.noteRecent(p, song("v2"), 200); + P.noteRecent(p, song("v1"), 300); + compare(Object.keys(p.recent).length, 2); + compare(p.recent["v1"].at, 300); + compare(ids(P.recentItems(p)), "v1,v2"); + } + + // Bounded recents: at capacity the OLDEST entries are evicted, never the newest — losing + // recent listening to a flood of stale rows would be the regression. + function test_recent_evicts_oldest() { + var p = P.empty(); + for (var i = 0; i < 105; i++) + P.noteRecent(p, song("v" + i), i + 1); // strictly increasing timestamps + compare(Object.keys(p.recent).length, 100); + verify(p.recent["v104"] !== undefined); // newest kept + verify(p.recent["v5"] !== undefined); // the five oldest gone, nothing newer + verify(p.recent["v4"] === undefined); + } + + // noteArtist stamps lastPlayedAt when a time is given (and refreshes it on later plays) while + // the count still climbs; called without one it stays backward-compatible and adds no field. + function test_note_artist_last_played() { + var p = P.empty(); + P.noteArtist(p, "UCa", "A", 100); + compare(p.artists["UCa"].lastPlayedAt, 100); + P.noteArtist(p, "UCa", "A", 500); + compare(p.artists["UCa"].count, 2); + compare(p.artists["UCa"].lastPlayedAt, 500); + P.noteArtist(p, "UCb", "B"); // legacy call: no timestamp, no field + verify(!("lastPlayedAt" in p.artists["UCb"])); + } + + // Signing out of YouTube forgets only what that session can reopen — Liked Music and the + // account's own "Your …" library playlists — and leaves public playlists, songs and Spotify + // recents. The cross-provider boundary: a YouTube sign-out must not drop a Spotify recent. + function test_forget_youtube_recents() { + var p = P.empty(); + P.noteRecent(p, { kind: "playlist", id: "VLLM", title: "Liked Music" }, 1); + P.noteRecent(p, { kind: "playlist", id: "LM", title: "LM" }, 2); + P.noteRecent(p, { kind: "playlist", id: "PLmine", title: "Mine", subtitle: "Your playlist \u00b7 12 songs" }, 3); + P.noteRecent(p, { kind: "playlist", id: "PLpublic", title: "Chart", subtitle: "YouTube Music \u00b7 50 songs" }, 4); + P.noteRecent(p, { kind: "playlist", id: "spotify:playlist:abc", title: "Mix", subtitle: "Your Mix" }, 5); + P.noteRecent(p, song("v1"), 6); + compare(P.forgetYouTubeRecents(p), 3); + verify(!p.recent["VLLM"] && !p.recent["LM"] && !p.recent["PLmine"]); + verify(!!p.recent["PLpublic"] && !!p.recent["v1"]); + verify(!!p.recent["spotify:playlist:abc"]); // a Spotify item survives a YouTube sign-out + compare(P.forgetYouTubeRecents(P.empty()), 0); + } + + // Signing out of Spotify forgets only provider-qualified spotify: recents and leaves everything + // else, including a YouTube "Your …" playlist and songs — the mirror image of the boundary above. + function test_forget_spotify_recents() { + var p = P.empty(); + P.noteRecent(p, { kind: "playlist", id: "spotify:playlist:abc", title: "Mix", subtitle: "Your Mix" }, 1); + P.noteRecent(p, { kind: "album", id: "spotify:album:xyz", title: "Album" }, 2); + P.noteRecent(p, { kind: "playlist", id: "VLLM", title: "Liked Music" }, 3); + P.noteRecent(p, song("v1"), 4); + compare(P.forgetSpotifyRecents(p), 2); + verify(!p.recent["spotify:playlist:abc"] && !p.recent["spotify:album:xyz"]); + verify(!!p.recent["VLLM"] && !!p.recent["v1"]); + } + + // deepEqual recognises a save's echo despite serde reordering keys at any depth (so the client + // drops it), and reports a genuine change as different (so another client's edit still applies) + // — what keeps a stale full-blob echo from reverting a mutation made while the save was in flight. + function test_deep_equal_echo_detection() { + var a = { picks: [], recent: { v1: { kind: "song", id: "v1", at: 100 } }, artists: { UCx: { name: "X", count: 1 } } }; + var b = { artists: { UCx: { count: 1, name: "X" } }, recent: { v1: { at: 100, id: "v1", kind: "song" } }, picks: [] }; + verify(P.deepEqual(a, b)); // key order never matters, at any depth + var c = JSON.parse(JSON.stringify(a)); + c.recent["v2"] = { kind: "song", id: "v2", at: 200 }; + verify(!P.deepEqual(a, c)); // a newer mutation makes the echo stale + verify(P.deepEqual([1, "a", true, null], [1, "a", true, null])); + verify(!P.deepEqual([1, 2], [1, 2, 3])); + verify(!P.deepEqual({ a: 1 }, { a: 1, b: 2 })); + verify(!P.deepEqual({ a: 1 }, null)); + } +} diff --git a/client/tests/tst_playback.qml b/client/tests/tst_playback.qml new file mode 100644 index 0000000..c261787 --- /dev/null +++ b/client/tests/tst_playback.qml @@ -0,0 +1,121 @@ +import QtQuick +import QtTest +import "../lib/playback.js" as PB + +// applyEvent is the daemon-event -> state reducer. It is a pure function over a plain state object +// so this runs under qmltestrunner without Quickshell (Playback imports Daemon -> Quickshell). Each +// case checks a rule that is easy to get wrong: a track change resetting the transport, an in-place +// queue splice, and the seek-drag guard that keeps position echoes from fighting the held thumb. +TestCase { + name: "Playback" + + function freshState() { + return { + now: null, queue: { items: [], currentIndex: 0 }, + position: 0, duration: 0, paused: false, volume: 100, + stopAfterCurrent: false, rating: "indifferent", + pendingVideoId: "pending", lastError: "old", + seekDrag: NaN, volDrag: false, + lyrics: { synced: false, lines: [] }, auth: { signedIn: false }, + settings: ({}), lt: { role: "none" } + }; + } + + // A track change resets duration from the new row and snaps position to 0. + function test_now_playing_resets_position_and_duration() { + var s = freshState(); + s.position = 42; s.duration = 100; + PB.applyEvent(s, "now-playing", { videoId: "abc", duration: "2:53", rating: "like" }); + compare(s.duration, 173); + compare(s.position, 0); + compare(s.now.videoId, "abc"); + compare(s.rating, "like"); + compare(s.pendingVideoId, null); + compare(s.lastError, null); + } + + // queue-index keeps the array already held and patches only the current row. + function test_queue_index_splices_current_row() { + var s = freshState(); + s.queue = { items: [{ video_id: "a" }, { video_id: "b" }, { video_id: "c" }], currentIndex: 0 }; + var items = s.queue.items; + PB.applyEvent(s, "queue-index", { + current: { video_id: "b2", title: "Patched" }, currentIndex: 1, + playedFrom: 0, shuffle: false, repeat: "off", sourceName: "Liked Music" + }); + compare(s.queue.items.length, 3); + compare(s.queue.items[1].video_id, "b2"); // current row patched in + compare(s.queue.items[0].video_id, "a"); // the others are untouched + compare(s.queue.currentIndex, 1); + compare(s.queue.sourceName, "Liked Music"); + verify(s.queue.items === items); // same array kept, not rebuilt + } + + // position is applied normally, but ignored while a seek drag pins the thumb, then resumes. + function test_position_ignored_while_seek_dragging() { + var s = freshState(); + s.duration = 200; + PB.applyEvent(s, "position", { position: 12.5 }); + compare(s.position, 12.5); // applied when not dragging + s.seekDrag = 80; + PB.applyEvent(s, "position", { position: 30 }); + compare(s.position, 12.5); // echo suppressed under the thumb + s.seekDrag = NaN; + PB.applyEvent(s, "position", { position: 30 }); + compare(s.position, 30); // applied again on release + } + + // volume is applied from the daemon, unless the slider is being dragged. + function test_volume_ignored_while_volume_dragging() { + var s = freshState(); + PB.applyEvent(s, "volume", 42); + compare(s.volume, 42); + s.volDrag = true; + PB.applyEvent(s, "volume", 90); + compare(s.volume, 42); // echo ignored mid-drag + } + + // A late startup restore (the snapshot said signedIn:false) must clear the sign-in gate. + function test_spotify_restore_announcement_signs_the_client_in() { + var s = freshState(); + s.spotify = { signedIn: false, stored: true, name: null, premium: null }; + compare(PB.applyEvent(s, "spotify-auth", { state: "restored" }), null); + compare(s.spotify.signedIn, true); + compare(s.spotify.stored, true); + compare(s.spotify.premium, true); + // A dead cached session says why instead of silently demanding a sign-in. + PB.applyEvent(s, "spotify-auth", { state: "restore_failed", message: "bad credentials" }); + compare(s.spotify.signedIn, false); + compare(s.spotify.stored, true); + compare(s.spotify.error, "bad credentials"); + } + + // The SoundCloud browser flow drives its own mirror: waiting, sign-in, expiry, sign-out. + function test_soundcloud_auth_events_mirror_the_browser_flow() { + var s = freshState(); + s.soundcloud = { signedIn: false, name: null, error: "" }; + // Opening the browser is progress, not an error: an info toast plus a guidance line. + var wait = PB.applyEvent(s, "soundcloud-auth", { state: "waiting" }); + compare(wait.kind, "info"); + compare(s.soundcloud.signedIn, false); + verify(s.soundcloud.error.indexOf("browser") >= 0); + // Importing the session from the browser completes the sign-in. + var fx = PB.applyEvent(s, "soundcloud-auth", { state: "signed_in", name: "nero" }); + compare(s.soundcloud.signedIn, true); + compare(s.soundcloud.name, "nero"); + compare(s.soundcloud.error, ""); + compare(fx.kind, "success"); + // Giving up clears the guidance line with a calm info toast, not an error. + var expired = PB.applyEvent(s, "soundcloud-auth", { state: "expired" }); + compare(expired.kind, "info"); + compare(s.soundcloud.signedIn, false); + compare(s.soundcloud.error, ""); + // An expired persisted token reports itself and keeps the reason for the settings row. + PB.applyEvent(s, "soundcloud-auth", { state: "restore_failed", message: "expired" }); + compare(s.soundcloud.signedIn, false); + compare(s.soundcloud.error, "expired"); + PB.applyEvent(s, "soundcloud-auth", { state: "signed_out" }); + compare(s.soundcloud.signedIn, false); + compare(s.soundcloud.error, ""); + } +} diff --git a/client/tests/tst_playlist_links.qml b/client/tests/tst_playlist_links.qml new file mode 100644 index 0000000..bf6be69 --- /dev/null +++ b/client/tests/tst_playlist_links.qml @@ -0,0 +1,107 @@ +import QtQuick +import QtTest +import "../lib/playlist-links.js" as Links + +// The pasted-link parser behind the shortcut picker's YouTube playlist field, run without +// Quickshell (it is a pure module, mirroring tst_personal). It must open exactly the playlist a +// supported YouTube/YTM link points at, canonicalise the id to the `VL…` browse id `get_playlist` +// wants, and refuse — clearly, by reason — a foreign host, a foreign scheme, or a link with a +// missing, malformed or ambiguous playlist id. Boundaries only; the picker owns the UI. +TestCase { + name: "PlaylistLinks" + + function parse(s) { return Links.parsePlaylistLink(s); } + + // Every URL form we accept resolves to the same VL-prefixed browse id, from a /playlist link, a + // track shared out of a playlist (watch / youtu.be with list=), a bare-host paste, and a YTM + // /browse page. The VL prefix is added once and never doubled. + function test_valid_forms() { + var a = parse("https://music.youtube.com/playlist?list=PLabc123"); + verify(a.ok); + compare(a.id, "VLPLabc123"); + compare(a.rawId, "PLabc123"); + + // A list id already carrying VL is not doubled. + compare(parse("https://music.youtube.com/playlist?list=VLPLabc123").id, "VLPLabc123"); + + // A track shared out of a playlist: the picker adds the playlist, not the single track. + compare(parse("https://music.youtube.com/watch?v=dQw4&list=PLabc").id, "VLPLabc"); + compare(parse("https://youtu.be/dQw4?list=PLabc&si=xyz").id, "VLPLabc"); + + // An album's audio playlist opens as a playlist of the same tracks. + compare(parse("https://www.youtube.com/playlist?list=OLAK5uy_abc").id, "VLOLAK5uy_abc"); + // An auto-generated mix carries its RD id through. + compare(parse("https://music.youtube.com/playlist?list=RDCLAK5uy_x").id, "VLRDCLAK5uy_x"); + + // Other supported hosts, a bare host paste, and surrounding whitespace. + compare(parse("https://m.youtube.com/playlist?list=PLabc").id, "VLPLabc"); + compare(parse("music.youtube.com/playlist?list=PLabc").id, "VLPLabc"); + compare(parse(" https://youtu.be/dQw4?list=PLabc ").id, "VLPLabc"); + + // YTM's own /browse page for a playlist is already a VL browse id. + var b = parse("https://music.youtube.com/browse/VLPLabc"); + verify(b.ok); + compare(b.id, "VLPLabc"); + compare(b.rawId, "PLabc"); + } + + // A host that is not YouTube, and a lookalike host, are refused as "host" — never fetched. + function test_wrong_host() { + var a = parse("https://example.com/playlist?list=PLabc"); + verify(!a.ok); + compare(a.reason, "host"); + + var b = parse("https://youtube.com.evil.test/playlist?list=PLabc"); + verify(!b.ok); + compare(b.reason, "host"); + } + + // A foreign scheme is refused as "scheme"; only http/https (or a bare host) are opened. + function test_wrong_scheme() { + var a = parse("ftp://music.youtube.com/playlist?list=PLabc"); + verify(!a.ok); + compare(a.reason, "scheme"); + + // file: and other schemes are refused too. + verify(!parse("file:///etc/passwd").ok); + // Plain text that is not a link at all is neither a host nor a scheme. + compare(parse("not a url at all").reason, "invalid"); + } + + // A supported link with no playlist to open — the bare site, a single track, an empty /playlist, + // or a /browse page that is an album/artist — reports the missing playlist rather than guessing. + function test_missing_playlist() { + compare(parse("https://music.youtube.com/").reason, "no-playlist"); + compare(parse("https://music.youtube.com/watch?v=dQw4").reason, "no-playlist"); + compare(parse("https://music.youtube.com/playlist").reason, "no-playlist"); + compare(parse("https://music.youtube.com/browse/FEmusic_home").reason, "no-playlist"); + compare(parse("https://music.youtube.com/browse/MPREb_album").reason, "no-playlist"); + compare(parse("https://music.youtube.com/browse/UCartist").reason, "no-playlist"); + } + + // A list= that is present but malformed — empty, VL with nothing after it, or non-id characters + // — is a "bad-id", not a fetchable playlist. + function test_bad_id() { + compare(parse("https://music.youtube.com/playlist?list=").reason, "bad-id"); + compare(parse("https://music.youtube.com/playlist?list=VL").reason, "bad-id"); + // A decoded space is not a valid id character. + compare(parse("https://music.youtube.com/playlist?list=PL%20abc").reason, "bad-id"); + } + + // Two different list= values are ambiguous; the same value twice is not. + function test_ambiguous_id() { + var a = parse("https://music.youtube.com/playlist?list=PLaaa&list=PLbbb"); + verify(!a.ok); + compare(a.reason, "ambiguous"); + + compare(parse("https://music.youtube.com/playlist?list=PLaaa&list=PLaaa").id, "VLPLaaa"); + } + + // A blank box is a neutral "empty" state, not an error the field should shout about. + function test_empty_is_neutral() { + compare(parse("").reason, "empty"); + compare(parse(" ").reason, "empty"); + compare(parse(null).reason, "empty"); + compare(parse(undefined).reason, "empty"); + } +} diff --git a/client/tests/tst_recommendations.qml b/client/tests/tst_recommendations.qml new file mode 100644 index 0000000..36ce215 --- /dev/null +++ b/client/tests/tst_recommendations.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtTest +import "../lib/recommendations.js" as R + +// The Home discovery ranker, exercised as the pure function it is — no Quickshell, no daemon, no +// network — the way tst_personal.qml runs the personal reducers. Each case pins a rule that is easy +// to get wrong: full id dedup, the provider boundary, decayed replay fatigue, the MMR diversity +// pass (both directions of the relevance/diversity trade), an honest zero-history cold start, and +// that build never mutates the feed or the personal blob it reads. +TestCase { + name: "Recommendations" + + // A fixed clock so decay maths is exact and the suite is deterministic. + readonly property double now: 1000 * 86400000 // day 1000, in ms + readonly property double day: 86400000 + + function song(id, artist) { + return { id: id, kind: "song", title: id, subtitle: artist, artistId: artist, thumbnail: "t" }; + } + function sec(title, items) { return { title: title, items: items }; } + function ids(list) { return list.map(function (x) { return x.id; }).join(","); } + + // Full id dedup across sections: an id seen again — even in a later shelf, even with different + // metadata — is dropped, and the first (best-ranked) occurrence is the one kept. + function test_dedup_across_sections() { + var secs = [ + sec("A", [song("x", "UCx"), song("y", "UCy")]), + sec("B", [song("x", "UCx"), song("z", "UCz")]) + ]; + var out = R.build(secs, {}, "youtube", now).items; + compare(out.filter(function (i) { return i.id === "x"; }).length, 1); // x appears once + compare(out.length, 3); // x, y, z + } + + // Provider boundary: a YouTube feed drops rows that positively belong to another catalogue + // (Spotify/SoundCloud uris and on-disk LOCAL ids); the provider's own rows stay. + function test_cross_provider_exclusion() { + var secs = [sec("A", [ + song("vid1", "UCa"), + song("spotify:track:foo", "spotify:artist:z"), + song("sc:track:123", "sc:artist:45"), + song("LOCAL:1", "UCb"), + song("vid2", "UCc") + ])]; + var out = R.build(secs, {}, "youtube", now).items; + compare(ids(out), "vid1,vid2"); + } + + // Replay fatigue: a track played very recently is pushed below an otherwise weaker (later-ranked) + // row, but the penalty decays — the same play long ago no longer demotes it. + function test_replay_fatigue_decays() { + var secs = [sec("A", [song("A", "UCa"), song("B", "UCb")])]; + + var justPlayed = { recent: { "A": { at: now - day } } }; // A played ~1 day ago + var fresh = R.build(secs, justPlayed, "youtube", now).items; + verify(ids(fresh).indexOf("B") < ids(fresh).indexOf("A")); // B overtakes fatigued A + + var longAgo = { recent: { "A": { at: now - 40 * day } } }; // fatigue decayed away + var stale = R.build(secs, longAgo, "youtube", now).items; + compare(ids(stale), "A,B"); // rank order restored + } + + // MMR diversity, both directions. When a different-artist row is close in relevance it is lifted + // above the second same-artist row; when it is genuinely far weaker, the strong same-artist row + // still wins. And the whole pass is deterministic — same inputs, same order, every call. + function test_diversity_rerank_and_determinism() { + // Close call: A,B share an artist, C is a fresh artist one rank back -> C beats B. + var close = [sec("S", [song("A", "UCx"), song("B", "UCx"), song("C", "UCy")])]; + var lifted = R.build(close, {}, "youtube", now).items; + compare(lifted[0].id, "A"); // top relevance leads + verify(ids(lifted).indexOf("C") < ids(lifted).indexOf("B")); // diversity lifts C + + // Genuinely weak alternative: the fresh-artist D sits far down the feed; the strong + // same-artist B still outranks it — relevance is allowed to win. + var items = [song("A", "UCx"), song("B", "UCx")]; + for (var k = 0; k < 40; k++) + items.push(song("pad" + k, "UCx")); + items.push(song("D", "UCy")); + var out = R.build([sec("S", items)], {}, "youtube", now).items; + verify(ids(out).indexOf("B") < ids(out).indexOf("D")); + + // Determinism: identical inputs yield an identical order. + var a = R.build(close, {}, "youtube", now).items; + var b = R.build(close, {}, "youtube", now).items; + compare(ids(a), ids(b)); + } + + // Without history, distinct artists retain provider order and the feed stays untouched. + function test_zero_history_cold_start_and_purity() { + var items = [song("a", "UCa"), song("b", "UCb"), song("c", "UCc")]; + var secs = [sec("S", items)]; + var frozen = JSON.stringify(secs); + + var res = R.build(secs, {}, "youtube", now); + compare(ids(res.items), "a,b,c"); // provider order preserved + compare(JSON.stringify(secs), frozen); // feed untouched + } + + // Empty and placeholder rows must not become playable recommendations. + function test_empty_and_unplayable_feed() { + var empty = R.build([], {}, "youtube", now); + compare(empty.items.length, 0); + + // Rows missing an id or a title are the placeholder rows a discovery shelf must not promote. + var junk = R.build([sec("S", [{ id: "", title: "" }, { kind: "song" }, null])], {}, "youtube", now); + compare(junk.items.length, 0); + } + + // The shelf is bounded to twelve however long the feed is. + function test_caps_at_twelve() { + var items = []; + for (var k = 0; k < 20; k++) + items.push(song("s" + k, "UC" + k)); + compare(R.build([sec("S", items)], {}, "youtube", now).items.length, 12); + } + + // Listening history can change ranking only within its own provider. + function test_personalization_is_provider_scoped() { + var yt = [sec("S", [song("a", "UCa"), song("b", "UCb")])]; + var blob = { artists: { "UCb": { name: "B", count: 5, lastPlayedAt: now - 2 * day } }, recent: {} }; + var frozen = JSON.stringify(blob); + var hot = R.build(yt, blob, "youtube", now); + compare(ids(hot.items), "b,a"); + + var sp = [sec("S", [song("spotify:track:1", "spotify:artist:1"), + song("spotify:track:2", "spotify:artist:2")])]; + var cold = R.build(sp, blob, "spotify", now); + compare(ids(cold.items), "spotify:track:1,spotify:track:2"); + compare(JSON.stringify(blob), frozen); + } +} diff --git a/client/tests/tst_style.qml b/client/tests/tst_style.qml new file mode 100644 index 0000000..b67cde8 --- /dev/null +++ b/client/tests/tst_style.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtTest +import "../lib/style.js" as Style + +// Style's thumb()/fmtTime() are pure and live in lib/style.js precisely so this test can reach them: +// the Style singleton imports Tokens, which imports Quickshell, and a qmltestrunner process cannot +// load the Quickshell plugin. Importing the module directly tests the exact code Style re-exposes. +TestCase { + name: "Style" + + function test_thumb_rewrites_sizes() { + compare(Style.thumb("https://x/a=w120-h120-l90", 544), "https://x/a=w544-h544-l90"); + compare(Style.thumb("https://x/a=s200", 64), "https://x/a=s64"); + compare(Style.thumb("/music/cover.jpg", 64), "file:///music/cover.jpg"); + compare(Style.thumb("", 64), undefined); + } + + function test_fmtTime() { + compare(Style.fmtTime(0), "0:00"); + compare(Style.fmtTime(65.9), "1:05"); + compare(Style.fmtTime(3600), "60:00"); + } +} diff --git a/client/version b/client/version new file mode 100644 index 0000000..0664a8f --- /dev/null +++ b/client/version @@ -0,0 +1 @@ +1.1.6 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..9690e6b --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ryotunes-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes playback core: state, orchestration, integrations. No UI." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "fs", "process"] } +tracing = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +futures-util = { workspace = true } +rand = { workspace = true } +parking_lot = "0.12" +listen-protocol = { workspace = true } +innertube = { path = "../innertube" } +player = { path = "../player" } +ryotunes-spotify = { path = "../spotify" } +ryotunes-soundcloud = { path = "../soundcloud" } +rusqlite = { version = "0.32", features = ["bundled"] } +base64 = "0.22" +regex = "1" +urlencoding = "2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "stream"] } +souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +discord-rich-presence = "1.1.0" +md-5 = "0.10" +lofty = "0.22.2" +url = "2" +sha2 = "0.10" +tempfile = "3" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" diff --git a/src-tauri/cipher_configs.json b/crates/core/cipher_configs.json similarity index 100% rename from src-tauri/cipher_configs.json rename to crates/core/cipher_configs.json diff --git a/crates/core/examples/soundcloud_resolve.rs b/crates/core/examples/soundcloud_resolve.rs new file mode 100644 index 0000000..56b9631 --- /dev/null +++ b/crates/core/examples/soundcloud_resolve.rs @@ -0,0 +1,50 @@ +//! Live proof of the SoundCloud resolve path plus the `soundcloud_bridge` artist-page shape. +//! +//! Run (hits the network — needs internet): +//! `cargo run -p ryotunes-core --example soundcloud_resolve` +//! +//! It resolves `sc:track:580008897` to an HLS stream URL (the same call `AppState::resolve` makes +//! for a `sc:track:` id) and prints it, then builds `soundcloud_bridge::artist_page` for the user +//! "Klingande" and prints the JSON keys the client binds to. + +use ryotunes_core::soundcloud_bridge; +use ryotunes_soundcloud::SoundCloud; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cache = std::env::temp_dir().join("ryotunes-sc-example"); + let sc = SoundCloud::new(cache); + + // 1) Resolve sc:track:580008897 -> HLS m3u8 stream URL (mpv plays it directly). + let track = sc.track(580_008_897).await?; + let url = sc.stream_url(&track).await?; + println!("track : {} — {}", track.title, track.user.username); + println!("stream : {url}"); + assert!(url.starts_with("https://"), "stream url should be a plain https m3u8"); + + // 2) soundcloud_bridge::artist_page(user Klingande) — the get_artist shape for sc:user: ids. + let users = sc.search_users("Klingande").await?; + let user = users.into_iter().next().ok_or("no user found for Klingande")?; + println!("user : {} (sc:user:{}), {} followers", user.username, user.id, user.followers); + let uid = user.id; + let (tracks, albums, playlists, likes) = tokio::join!( + sc.user_tracks(uid, 0), + sc.user_albums(uid), + sc.user_playlists(uid), + sc.user_likes(uid), + ); + let tracks = tracks.map(|p| p.items).unwrap_or_default(); + let page = soundcloud_bridge::artist_page( + &user, + &tracks, + &albums.unwrap_or_default(), + &playlists.unwrap_or_default(), + &likes.unwrap_or_default(), + ); + let json = serde_json::to_value(&page)?; + let obj = json.as_object().expect("artist_page serializes to an object"); + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + println!("get_artist(sc:user:) keys: {keys:?}"); + println!("{}", serde_json::to_string_pretty(&json)?); + Ok(()) +} diff --git a/src-tauri/po_token.html b/crates/core/po_token.html similarity index 100% rename from src-tauri/po_token.html rename to crates/core/po_token.html diff --git a/src-tauri/src/cipher/config.rs b/crates/core/src/cipher/config.rs similarity index 100% rename from src-tauri/src/cipher/config.rs rename to crates/core/src/cipher/config.rs diff --git a/src-tauri/src/cipher/extractor.rs b/crates/core/src/cipher/extractor.rs similarity index 100% rename from src-tauri/src/cipher/extractor.rs rename to crates/core/src/cipher/extractor.rs diff --git a/src-tauri/src/cipher/fetcher.rs b/crates/core/src/cipher/fetcher.rs similarity index 87% rename from src-tauri/src/cipher/fetcher.rs rename to crates/core/src/cipher/fetcher.rs index 7390bec..cce0d3d 100644 --- a/src-tauri/src/cipher/fetcher.rs +++ b/crates/core/src/cipher/fetcher.rs @@ -53,7 +53,6 @@ impl PlayerJsFetcher { format!("https://www.youtube.com/s/player/{hash}/player_ias.vflset/en_GB/base.js"); let js = get(&url).await?.error_for_status()?.text().await?; std::fs::write(&cached, &js)?; - std::fs::write(self.cache_dir.join("current_hash.txt"), format!("{hash}\n{}", now_secs()))?; tracing::info!(hash, bytes = js.len(), "fetched fresh player.js"); Ok(PlayerJs { js, hash }) } @@ -74,9 +73,20 @@ impl PlayerJsFetcher { } } + /// The player hash, pinned on disk for the cache TTL so a listening session keeps one + /// player: YouTube hands different builds to consecutive `iframe_api` requests, and + /// following every answer meant a fresh 3 MB `base.js` and a webview rebuild per track. async fn current_hash(&self) -> Result { + let pin = self.cache_dir.join("current_hash.txt"); + if let Some(hash) = read_if_fresh(&pin).and_then(|s| s.lines().next().map(str::to_owned)) { + if !hash.is_empty() { + return Ok(hash); + } + } let body = get(IFRAME_API).await?.error_for_status()?.text().await?; - extract_hash(&body).ok_or(Error::NoHash) + let hash = extract_hash(&body).ok_or(Error::NoHash)?; + std::fs::write(&pin, format!("{hash}\n{}", now_secs()))?; + Ok(hash) } } diff --git a/src-tauri/src/cipher/mod.rs b/crates/core/src/cipher/mod.rs similarity index 93% rename from src-tauri/src/cipher/mod.rs rename to crates/core/src/cipher/mod.rs index 885e89d..64b8752 100644 --- a/src-tauri/src/cipher/mod.rs +++ b/crates/core/src/cipher/mod.rs @@ -1,7 +1,7 @@ //! `CipherDeobfuscator` (cipher runtime) — the signature/`n`-transform runtime the orchestrator calls. //! //! Ties [`fetcher`] (player.js) + [`extractor`]/[`config`] (function names) + a hidden cipher -//! webview ([`crate::webview`]) that runs YouTube's own code. Every public method degrades +//! webview (the host's `webview.rs`) that runs YouTube's own code. Every public method degrades //! gracefully: a webview or extraction failure yields `None` / the original URL, and the //! orchestrator falls through to the non-cipher fallback clients (stream selection §5). @@ -16,10 +16,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use serde_json::Value; -use tauri::AppHandle; use tokio::sync::Mutex; -use crate::webview::Bridge; +use crate::host::{JsBridge, JsSession}; use fetcher::PlayerJsFetcher; const CIPHER_LABEL: &str = "ryotunes-cipher"; @@ -49,7 +48,7 @@ const DISCOVERY_JS: &str = r#"(function(){ #[derive(Default)] struct Inner { - bridge: Option, + bridge: Option>, sts: Option, built_epoch: u64, n_available: bool, @@ -67,19 +66,19 @@ struct Inner { } pub struct CipherDeobfuscator { - app: AppHandle, + js: Arc, fetcher: PlayerJsFetcher, config: Arc, inner: Mutex, } impl CipherDeobfuscator { - pub fn new(app: AppHandle, app_data_dir: &Path, config: Arc) -> Self { + pub fn new(js: Arc, app_data_dir: &Path, config: Arc) -> Self { CipherDeobfuscator { fetcher: PlayerJsFetcher::new(app_data_dir), config, inner: Mutex::new(Inner::default()), - app, + js, } } @@ -112,7 +111,7 @@ impl CipherDeobfuscator { let mut inner = self.inner.lock().await; inner.analyzed = false; // force re-fetch + re-analysis if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } @@ -122,7 +121,7 @@ impl CipherDeobfuscator { async fn try_deobfuscate(&self, cipher: &str) -> Option { self.ensure_analyzed().await.ok()?; let (s, sp, base) = parse_cipher(cipher)?; - let bridge = self.inner.lock().await.bridge.clone()?; + let bridge = self.inner.lock().await.bridge.as_ref().map(|b| b.clone_session())?; let js = format!( "(function(){{try{{return String(window._cipherSigFunc({}));}}catch(e){{return null;}}}})()", js_string(&s) @@ -150,7 +149,7 @@ impl CipherDeobfuscator { if !inner.n_available { return None; } - let bridge = inner.bridge.clone()?; + let bridge = inner.bridge.as_ref().map(|b| b.clone_session())?; drop(inner); let re = regex::Regex::new(r"[?&]n=([^&]+)").ok()?; @@ -179,7 +178,7 @@ impl CipherDeobfuscator { let mut inner = self.inner.lock().await; inner.analyzed = false; // next ensure_analyzed rebuilds if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } @@ -203,7 +202,7 @@ impl CipherDeobfuscator { inner.bridge.is_some() && inner.last_used.is_some_and(|used| used.elapsed() >= idle); if expired { if let Some(bridge) = inner.bridge.take() { - let _ = bridge.destroy(); + bridge.destroy(); } inner.last_used = None; tracing::debug!(?idle, "cipher webview torn down (idle)"); @@ -235,7 +234,7 @@ impl CipherDeobfuscator { // Unknown player hash — pull the registries off the hot path; a validated config for it // lands on the next rebuild (cipher runtime §forceRefresh). This run can't decipher. let config = self.config.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { config.force_refresh().await; }); } @@ -251,7 +250,7 @@ impl CipherDeobfuscator { if cfg.is_none() { let mut inner = self.inner.lock().await; if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; inner.sts = sts; @@ -273,15 +272,13 @@ impl CipherDeobfuscator { { let mut inner = self.inner.lock().await; if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } - let bridge = Bridge::create(&self.app, CIPHER_LABEL, HARNESS, "") - .await - .map_err(|e| e.to_string())?; - if let Err(e) = Self::load_player(&bridge, &injected).await { - let _ = bridge.destroy(); // don't orphan the hidden window on a failed load + let bridge = self.js.create(CIPHER_LABEL, HARNESS, "").await.map_err(|e| e.to_string())?; + if let Err(e) = Self::load_player(&*bridge, &injected).await { + bridge.destroy(); // don't orphan the hidden window on a failed load return Err(e); } let n_available = matches!( @@ -302,7 +299,7 @@ impl CipherDeobfuscator { "cipher: discovery found no usable sig/n on this player — dropping the webview \ (KI-1; rebuilt on config-epoch change or self-heal)" ); - let _ = bridge.destroy(); + bridge.destroy(); inner.bridge = None; inner.last_used = None; } @@ -317,7 +314,7 @@ impl CipherDeobfuscator { /// Inject player.js + discovery into a freshly-built cipher `bridge` and wait for discovery to /// finish. Split out so `ensure_analyzed` can destroy the webview on any of these failures. - async fn load_player(bridge: &Bridge, injected: &str) -> Result<(), String> { + async fn load_player(bridge: &dyn JsSession, injected: &str) -> Result<(), String> { bridge.eval(injected).map_err(|e| e.to_string())?; bridge.eval(DISCOVERY_JS).map_err(|e| e.to_string())?; // Wait for discovery to finish, then the caller reads whether n/sig are usable. diff --git a/src-tauri/src/db.rs b/crates/core/src/db.rs similarity index 96% rename from src-tauri/src/db.rs rename to crates/core/src/db.rs index a9b1c12..5aa310d 100644 --- a/src-tauri/src/db.rs +++ b/crates/core/src/db.rs @@ -716,6 +716,26 @@ impl Db { out } + /// The first four distinct, nonempty artwork URLs in playlist order. Extract only the + /// thumbnail field and bound the result in SQL so library cards never allocate every + /// song's artwork just to draw one cover. + pub fn local_playlist_track_thumbnails(&self, playlist_id: &str) -> Vec { + let conn = self.0.lock().unwrap(); + let mut out = Vec::new(); + if let Ok(mut stmt) = conn.prepare( + "SELECT trim(json_extract(song_json, '$.thumbnail')) AS thumbnail + FROM local_playlist_tracks + WHERE playlist_id = ?1 AND json_type(song_json, '$.thumbnail') = 'text' + AND length(trim(json_extract(song_json, '$.thumbnail'))) > 0 + GROUP BY thumbnail ORDER BY MIN(position) ASC LIMIT 4", + ) { + if let Ok(rows) = stmt.query_map([playlist_id], |r| r.get::<_, Option>(0)) { + out.extend(rows.flatten().flatten()); + } + } + out + } + fn local_playlist_memberships(&self) -> std::collections::HashMap> { let conn = self.0.lock().unwrap(); let mut out: std::collections::HashMap> = @@ -991,6 +1011,24 @@ mod tests { assert!(!d.playlist_memberships().contains_key("b")); } + #[test] + fn device_playlist_thumbnails_extract_in_order_and_skip_missing() { + let d = db(); + let id = "RYOTUNES_LOCAL_PLAYLIST:art"; + d.create_local_playlist(id, "Art mix").unwrap(); + d.add_local_playlist_track(id, "a", r#"{"video_id":"a","thumbnail":"https://img/a"}"#) + .unwrap(); + // A track whose SongItem carried no thumbnail is skipped, never surfaced as an empty slot. + d.add_local_playlist_track(id, "b", r#"{"video_id":"b"}"#).unwrap(); + d.add_local_playlist_track(id, "c", r#"{"video_id":"c","thumbnail":"https://img/c"}"#) + .unwrap(); + assert_eq!( + d.local_playlist_track_thumbnails(id), + vec!["https://img/a".to_string(), "https://img/c".to_string()], + "json_extract pulls thumbnails in playlist order and drops tracks without one" + ); + } + #[test] fn opening_the_db_clears_local_files_out_of_on_repeat() { // 0.3.1 counted local plays before On Repeat excluded them; opening the db drops the rows. diff --git a/src-tauri/src/discord.rs b/crates/core/src/discord.rs similarity index 83% rename from src-tauri/src/discord.rs rename to crates/core/src/discord.rs index 765998d..6d093ab 100644 --- a/src-tauri/src/discord.rs +++ b/crates/core/src/discord.rs @@ -39,22 +39,30 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender, TryRecvError}; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; use innertube::SongItem; -/// Discord application id (a snowflake — digits only). **Must be set before rich presence does -/// anything.** Register an app named "Ryotunes" at and -/// paste its Application ID here — the app's *name* is what renders after "Listening to", and its -/// icon is the fallback artwork. Nothing else in the portal needs configuring: no bot user, no -/// OAuth redirect, no client secret. (Metrolist needs all of that only because Android has no -/// Discord client.) +/// Discord application id (a snowflake — digits only). This id is inherited from upstream LiMusic: +/// Discord's `oauth2/applications//rpc` still returns the name "Limusic" and its lemon icon. +/// That portal identity is **not** Ryotunes-owned and can only be changed by whoever holds the app +/// in Discord's developer portal, so a Ryotunes-owned Application ID is pending; until one exists +/// this stays put rather than repointing at an unrelated app. The card no longer leans on it — it +/// sets its own activity `name` ([`DEFAULT_PRESENCE_NAME`]) and artwork ([`BRAND_IMAGE`]). No bot +/// user, OAuth redirect or client secret is needed. const APP_ID: &str = "1525891596804161727"; const SONG_URL: &str = "https://music.youtube.com/watch?v="; +/// Ryotunes' brand mark (public repo PNG). The card's large-image fallback when a track has no +/// fetchable artwork, and the small badge on every art card — so the card never falls back to the +/// application's portal icon (the upstream lemon). Verified reachable, `200 image/png` (512×512). +const BRAND_IMAGE: &str = + "https://raw.githubusercontent.com/ryoku-dev/ryotunes/main/src-tauri/icons/icon.png"; +const BRAND_BADGE_TEXT: &str = "Ryotunes"; + /// Reconnect backoff while enabled but unconnected (Discord not running, or it quit). Starts short /// — Discord may simply be slower to start than we are — and eases off so a permanently-absent /// Discord costs one wakeup every `CONNECT_RETRY_MAX`. @@ -497,33 +505,28 @@ impl Presence { .status_display_type(activity::StatusDisplayType::State) .details(field(&track.title)) .timestamps(ts); - // A local file has no YouTube page to link, and its id is a path — never put it in a URL. - let mut buttons = Vec::new(); - if !crate::local::is_local_song(&track.video_id) - && !crate::radio::is_radio_id(&track.video_id) - { - buttons.push(activity::Button::new( - "Listen on YouTube Music", - format!("{SONG_URL}{}", track.video_id), - )); - } - if !buttons.is_empty() { - act = act.buttons(buttons); + // Route by provider so a Spotify/SoundCloud id never ships as a YouTube URL. `None` = no + // verifiable page (local path, radio, numeric SoundCloud, or an unknown id) — no button. + if let Some((label, url)) = listen_link(&track.video_id) { + act = act.buttons(vec![activity::Button::new(label, url)]); } if !track.artists.is_empty() { act = act.state(field(&track.artists)); } - // Unlike the Gateway, the IPC client accepts a plain https URL here and proxies it itself — - // no `external-assets` round-trip. Artwork is best-effort: no thumbnail is just a - // text-only presence. - if let Some(url) = track.thumbnail.clone() { - let mut assets = activity::Assets::new().large_image(url); - match &track.album { - Some(album) if !album.is_empty() => assets = assets.large_text(field(album)), - _ => {} + // Artwork is a plain https URL the IPC client proxies. The hero is the track's art; with + // none to show, the Ryotunes mark stands in — never the application's lemon icon. + let mut assets = activity::Assets::new(); + match track.thumbnail.clone() { + // Real art up top: the small badge is the Ryotunes mark. + Some(url) => { + assets = + assets.large_image(url).small_image(BRAND_IMAGE).small_text(BRAND_BADGE_TEXT); } - act = act.assets(assets); + // No art: the brand mark is the hero; a badge would just repeat it. + None => assets = assets.large_image(BRAND_IMAGE), } + assets = assets.large_text(art_hover(&track)); + act = act.assets(assets); // The floor is charged for every frame we put on the wire, accepted or not. self.last_send = Some(Instant::now()); @@ -644,38 +647,86 @@ fn field(s: &str) -> String { out } -/// Ready a thumbnail URL for Discord's card: request a decent resolution (stored thumbs are often -/// row-sized, 60px) and refuse URLs over Discord's length limit. Mirrors `ui/src/lib/thumb.ts` — -/// only googleusercontent-style URLs carry their size in the URL; i.ytimg path-variant thumbs pass -/// through unchanged (other sizes can 404). +/// Ready a thumbnail URL for Discord's card: upscale (stored thumbs are often 60px), then accept +/// only a plain https URL with a real host, no credentials, and within Discord's length limit — +/// anything else (empty, `file:`, `user:pass@…`) would fail to load and let Discord fall back to +/// the app icon, so it's dropped to `None` and the brand fallback stands in. Mirrors +/// `ui/src/lib/thumb.ts`: googleusercontent URLs size in-URL; i.ytimg path-variant thumbs pass through. fn discord_thumb(url: &str) -> Option { - static WH: OnceLock = OnceLock::new(); - static S: OnceLock = OnceLock::new(); - let wh = WH.get_or_init(|| regex::Regex::new(r"=w\d+-h\d+").expect("static regex")); - let s = S.get_or_init(|| regex::Regex::new(r"=s\d+").expect("static regex")); - let sized = if wh.is_match(url) { - wh.replace(url, "=w512-h512").into_owned() - } else if s.is_match(url) { - s.replace(url, "=s512").into_owned() + static WH: LazyLock = + LazyLock::new(|| regex::Regex::new(r"=w\d+-h\d+").expect("static regex")); + static S: LazyLock = + LazyLock::new(|| regex::Regex::new(r"=s\d+").expect("static regex")); + let sized = if WH.is_match(url) { + WH.replace(url, "=w512-h512").into_owned() + } else if S.is_match(url) { + S.replace(url, "=s512").into_owned() } else { url.to_owned() }; - (sized.len() <= MAX_ASSET_URL).then_some(sized) + if sized.len() > MAX_ASSET_URL { + return None; + } + let parsed = ::url::Url::parse(&sized).ok()?; + let has_host = parsed.host_str().map_or(false, |h| !h.is_empty()); + if parsed.scheme() != "https" + || !has_host + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return None; + } + Some(sized) +} + +/// The "listen" button for a track id — label and canonical URL — or `None` when there's no +/// verifiable page: Spotify tracks link `open.spotify.com`, well-formed YouTube ids link YouTube +/// Music. A numeric SoundCloud id, a local path, a radio stream, or any unrecognised id gets no +/// button rather than a fabricated one. Pure, so provider routing is testable without a socket. +fn listen_link(video_id: &str) -> Option<(&'static str, String)> { + if let Some(id) = crate::spotify::spotify_track_id(video_id) { + return Some(("Listen on Spotify", format!("https://open.spotify.com/track/{id}"))); + } + if is_youtube_id(video_id) { + return Some(("Listen on YouTube Music", format!("{SONG_URL}{video_id}"))); + } + None +} + +/// YouTube's video-id shape: exactly 11 chars of `[A-Za-z0-9_-]`. +fn is_youtube_id(id: &str) -> bool { + id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Which service a track id came from, for the card's provider-context line. +fn provider_label(video_id: &str) -> &'static str { + if crate::local::is_local_song(video_id) { + "Local library" + } else if crate::radio::is_radio_id(video_id) { + "Radio" + } else if crate::spotify::is_sc_id(video_id) { + "SoundCloud" + } else if crate::spotify::is_spotify_id(video_id) { + "Spotify" + } else { + "YouTube Music" + } +} + +/// Large-image hover: " · ", or just the provider when there's no album. Clamped +/// to Discord's field window; a local track contributes only "Local library", never its path. +fn art_hover(track: &Track) -> String { + let provider = provider_label(&track.video_id); + match track.album.as_deref().map(str::trim).filter(|a| !a.is_empty()) { + Some(album) => field(&format!("{album} · {provider}")), + None => field(provider), + } } #[cfg(test)] mod tests { use super::*; - /// `spawn` refuses to run without one, so a bad edit here silently disables the whole feature. - #[test] - fn app_id_is_a_snowflake() { - assert!( - !APP_ID.is_empty() && APP_ID.bytes().all(|b| b.is_ascii_digit()), - "APP_ID must be a Discord application id (digits only) — got {APP_ID:?}" - ); - } - fn track(id: &str) -> Box { Box::new(Track { video_id: id.into(), @@ -903,5 +954,38 @@ mod tests { ); let long = format!("https://example.com/{}", "a".repeat(300)); assert_eq!(discord_thumb(&long), None, "over-long URLs are dropped, not sent"); + assert_eq!(discord_thumb("file:///home/u/cover.png"), None, "non-https art is dropped"); + assert_eq!( + discord_thumb("https://user:pass@example.com/c.png"), + None, + "credentialed URLs are dropped" + ); + } + + /// Provider routing is the correctness crux of the card: a Spotify or SoundCloud id must never + /// ship as a YouTube URL, and private or unlinkable ids get no button at all. + #[test] + fn listen_link_routes_by_provider() { + // YouTube: a bare video id links its YouTube Music watch page. + let (label, url) = listen_link("dQw4w9WgXcQ").expect("youtube ids link"); + assert_eq!(label, "Listen on YouTube Music"); + assert_eq!(url, "https://music.youtube.com/watch?v=dQw4w9WgXcQ"); + + // Spotify: linked to its open.spotify.com track page, never smuggled into a YouTube URL. + let sp = format!("{}4uLU6hMCjMI75M1A2tKUQC", crate::spotify::SPOTIFY_TRACK_PREFIX); + let (label, url) = listen_link(&sp).expect("spotify tracks link"); + assert_eq!(label, "Listen on Spotify"); + assert_eq!(url, "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"); + assert!(!url.contains("youtube"), "a spotify id must not become a youtube url"); + + // SoundCloud numeric id: no canonical page → no button (never a fabricated/widget link). + let sc = format!("{}959028242", crate::spotify::SC_TRACK_PREFIX); + assert_eq!(listen_link(&sc), None, "soundcloud numeric ids are omitted"); + + // Private / unlinkable: a local path and a radio stream carry no button. + assert_eq!(listen_link(&format!("{}Music/x.flac", crate::local::SONG_PREFIX)), None); + assert_eq!(listen_link(&format!("{}uuid-123", crate::radio::RADIO_ID_PREFIX)), None); + // An unrecognised bare id (not a valid 11-char YouTube id) is omitted, not made into a URL. + assert_eq!(listen_link("short"), None, "malformed ids get no fabricated youtube url"); } } diff --git a/crates/core/src/host.rs b/crates/core/src/host.rs new file mode 100644 index 0000000..b6a16d5 --- /dev/null +++ b/crates/core/src/host.rs @@ -0,0 +1,131 @@ +//! The seams between the core and whoever hosts it. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; + +/// Server-push channel: the host fans an event out to every listening UI. +pub trait EventSink: Send + Sync + 'static { + fn emit(&self, event: &'static str, payload: Value); +} + +#[derive(Debug, thiserror::Error)] +pub enum JsError { + #[error("js session '{0}' does not exist")] + Gone(String), + #[error("js eval failed: {0}")] + Eval(String), + #[error("timed out after {0:?}")] + Timeout(Duration), + #[error("js environment reported an error: {0}")] + BadEnvironment(String), + #[error("js environment build failed: {0}")] + Build(String), +} + +/// A JavaScript environment able to run YouTube's player.js and BotGuard harnesses. +#[async_trait::async_trait] +pub trait JsBridge: Send + Sync + 'static { + async fn create( + &self, + label: &str, + harness_html: &str, + init_script: &str, + ) -> Result, JsError>; + + /// Destroy any environment left behind under `label` by a cancelled `create` and wait until the + /// label is free again. Same semantics as the host's `destroy_and_wait`. + async fn reclaim(&self, label: &str); +} + +/// One live environment. Mirrors `src-tauri/src/webview.rs` `Bridge` one to one. +#[async_trait::async_trait] +pub trait JsSession: Send + Sync { + fn eval(&self, js: &str) -> Result<(), JsError>; + async fn eval_json(&self, js: String, timeout: Duration) -> Result; + async fn call_async(&self, expr: &str, timeout: Duration) -> Result; + fn exists(&self) -> bool; + fn destroy(&self); + /// A second handle to the same environment (the PoToken minter keeps one per session). + fn clone_session(&self) -> Box; +} + +#[derive(Debug, Clone)] +pub struct LoginResult { + /// `name=value` pairs for the `.youtube.com` domain, as the cookie jar hands them out. + pub cookies: Vec<(String, String)>, + /// Google account index the user picked (`authuser`). + pub authuser: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("sign-in was cancelled")] + Cancelled, + #[error("sign-in failed: {0}")] + Failed(String), +} + +/// The interactive Google sign-in, owned by the host because it needs a visible browser. +#[async_trait::async_trait] +pub trait LoginFlow: Send + Sync + 'static { + async fn sign_in(&self) -> Result; +} + +/// Where the core keeps its files. The host resolves them (Tauri's `app_data_dir`, or XDG). +#[derive(Debug, Clone)] +pub struct Paths { + pub data_dir: PathBuf, + pub cache_dir: PathBuf, +} + +impl Paths { + pub fn covers_dir(&self) -> PathBuf { + self.data_dir.join("covers") + } + pub fn db_path(&self) -> PathBuf { + self.data_dir.join("ryotunes.db") + } +} + +#[cfg(test)] +pub mod test_support { + use super::*; + use std::sync::Mutex; + + /// An `EventSink` that records what was emitted, for unit tests of the core. + #[derive(Default)] + pub struct RecordingSink { + pub events: Mutex>, + } + + impl EventSink for RecordingSink { + fn emit(&self, event: &'static str, payload: Value) { + self.events.lock().unwrap().push((event, payload)); + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::RecordingSink; + use super::*; + + #[test] + fn recording_sink_keeps_order() { + let sink = RecordingSink::default(); + sink.emit("playback-state", Value::String("playing".into())); + sink.emit("position", serde_json::json!({ "position": 1.5 })); + let events = sink.events.lock().unwrap(); + assert_eq!(events[0].0, "playback-state"); + assert_eq!(events[1].1["position"], 1.5); + } + + #[test] + fn paths_derive_children() { + let p = Paths { data_dir: "/d".into(), cache_dir: "/c".into() }; + assert_eq!(p.covers_dir(), PathBuf::from("/d/covers")); + assert_eq!(p.db_path(), PathBuf::from("/d/ryotunes.db")); + } +} diff --git a/src-tauri/src/http.rs b/crates/core/src/http.rs similarity index 100% rename from src-tauri/src/http.rs rename to crates/core/src/http.rs diff --git a/src-tauri/src/lastfm.rs b/crates/core/src/lastfm.rs similarity index 89% rename from src-tauri/src/lastfm.rs rename to crates/core/src/lastfm.rs index 070edd1..329b5b9 100644 --- a/src-tauri/src/lastfm.rs +++ b/crates/core/src/lastfm.rs @@ -23,9 +23,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use innertube::SongItem; use md5::{Digest, Md5}; -use tauri::Emitter; use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; +use crate::host::EventSink; use crate::state::AppState; /// Last.fm API credentials. Baked in at compile time from the gitignored `src-tauri/lastfm.keys` @@ -106,11 +106,13 @@ impl LastfmHandle { } } -/// Spawn the scrobbler task. `session_key` is the persisted login; `None` parks the task until -/// the user connects. -pub fn spawn(session_key: Option) -> LastfmHandle { +/// Spawn the scrobbler task on `rt`. `session_key` is the persisted login; `None` parks the task +/// until the user connects. Takes the runtime handle explicitly because the host calls this from +/// its synchronous setup (Tauri's `setup` closure runs on the main thread, outside any Tokio +/// context), where `tokio::spawn` would panic with "no reactor running". +pub fn spawn(rt: &tokio::runtime::Handle, session_key: Option) -> LastfmHandle { let (tx, mut rx) = unbounded_channel::(); - tauri::async_runtime::spawn(async move { + rt.spawn(async move { let mut s = Scrobbler::new(session_key); while let Some(msg) = rx.recv().await { s.apply(msg).await; @@ -276,13 +278,8 @@ fn sign(params: &[(String, String)]) -> String { // --- auth flow (connect / disconnect / status) ---------------------------------------------- -fn emit_state( - app: &tauri::AppHandle, - connected: bool, - username: Option<&str>, - error: Option<&str>, -) { - let _ = app.emit( +fn emit_state(sink: &dyn EventSink, connected: bool, username: Option<&str>, error: Option<&str>) { + sink.emit( "lastfm-state", serde_json::json!({ "configured": !API_KEY.is_empty() && !API_SECRET.is_empty(), @@ -311,7 +308,7 @@ pub async fn connect(state: Arc) -> Result<(), String> { open_browser(&format!("{AUTH_URL}?api_key={API_KEY}&token={token}"))?; - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { for _ in 0..AUTH_POLL_TRIES { tokio::time::sleep(AUTH_POLL_EVERY).await; if state.lastfm.gen() != gen { @@ -324,7 +321,7 @@ pub async fn connect(state: Arc) -> Result<(), String> { let key = body.pointer("/session/key").and_then(|v| v.as_str()); let (Some(name), Some(key)) = (name, key) else { emit_state( - &state.app, + state.sink.as_ref(), false, None, Some("Last.fm sent a malformed session"), @@ -335,17 +332,27 @@ pub async fn connect(state: Arc) -> Result<(), String> { state.db.set_setting("lastfm_username", name); state.lastfm.set_session(Some(key.to_string())); tracing::info!("last.fm connected"); - emit_state(&state.app, true, Some(name), None); + emit_state(state.sink.as_ref(), true, Some(name), None); return; } Err(e) if e.retryable() => continue, // not approved yet (or transient) — keep polling Err(e) => { - emit_state(&state.app, false, None, Some(&format!("Last.fm: {}", e.message))); + emit_state( + state.sink.as_ref(), + false, + None, + Some(&format!("Last.fm: {}", e.message)), + ); return; } } } - emit_state(&state.app, false, None, Some("Last.fm authorization timed out — try again")); + emit_state( + state.sink.as_ref(), + false, + None, + Some("Last.fm authorization timed out — try again"), + ); }); Ok(()) } @@ -355,7 +362,7 @@ pub fn disconnect(state: &AppState) { state.db.set_setting("lastfm_session_key", ""); state.db.set_setting("lastfm_username", ""); state.lastfm.set_session(None); - emit_state(&state.app, false, None, None); + emit_state(state.sink.as_ref(), false, None, None); } pub fn status(state: &AppState) -> serde_json::Value { @@ -372,7 +379,7 @@ fn browser_url(raw: &str) -> Result { if raw.len() > 4_096 { return Err("Link is too long to open safely.".into()); } - let url = tauri::Url::parse(raw).map_err(|_| "That is not a valid web link.")?; + let url = url::Url::parse(raw).map_err(|_| "That is not a valid web link.")?; if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { return Err("Only http:// and https:// links can be opened.".into()); } @@ -385,7 +392,7 @@ fn browser_url(raw: &str) -> Result { /// Open a validated URL in the user's default browser. Arguments go directly to an executable — /// never through a shell. In particular, Windows used to call `cmd.exe /C start`; a quote inside /// an IPC-supplied URL could then be re-parsed as command syntax. -pub(crate) fn open_browser(raw: &str) -> Result<(), String> { +pub fn open_browser(raw: &str) -> Result<(), String> { let url = browser_url(raw)?; #[cfg(target_os = "linux")] let cmd = std::process::Command::new("xdg-open").arg(&url).spawn(); @@ -444,4 +451,16 @@ mod tests { assert!(!crosses_threshold(120.0, 0.0)); assert!(crosses_threshold(240.0, 0.0)); } + + /// The host spawns the scrobbler from a synchronous setup closure on a thread with no + /// ambient Tokio context; the explicit handle is what keeps that from panicking. + #[test] + fn spawns_from_outside_the_runtime() { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + let handle = rt.handle().clone(); + let spawned = std::thread::spawn(move || spawn(&handle, None)) + .join() + .expect("spawn must not panic off-runtime"); + drop(spawned); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..fd3246c --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,22 @@ +//! Ryotunes playback core. Everything that is not a window lives here; the host (Tauri today, the +//! daemon tomorrow) supplies the three traits in [`host`]. +pub mod host; + +pub mod cipher; +pub mod db; +pub mod discord; +pub mod http; +pub mod lastfm; +pub mod listentogether; +pub mod local; +pub mod lyrics; +pub mod media; +pub mod orchestrator; +pub mod potoken; +pub mod radio; +pub mod session; +pub mod soundcloud_bridge; +pub mod spotify; +pub mod spotify_bridge; +pub mod state; +pub mod update; diff --git a/src-tauri/src/listentogether/mod.rs b/crates/core/src/listentogether/mod.rs similarity index 97% rename from src-tauri/src/listentogether/mod.rs rename to crates/core/src/listentogether/mod.rs index 69d4a9e..f55e195 100644 --- a/src-tauri/src/listentogether/mod.rs +++ b/crates/core/src/listentogether/mod.rs @@ -11,11 +11,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; +use crate::host::EventSink; use futures_util::{SinkExt, StreamExt}; use listen_protocol::{ ClientMessage, Playback, PlaybackKind, RoomState, ServerMessage, Suggestion, Track, User, }; -use tauri::{AppHandle, Emitter}; use tokio::sync::{mpsc, Mutex}; use tokio_tungstenite::tungstenite::Message; @@ -143,7 +143,7 @@ impl Inner { } pub struct LtSession { - app: AppHandle, + sink: Arc, inner: Arc>, sync_tx: mpsc::UnboundedSender, /// Bumped to cancel the running connection loop (leave / new connection). @@ -153,7 +153,7 @@ pub struct LtSession { impl LtSession { /// Create the session. Returns the receiver the bridge task drains to drive guest playback. pub fn new( - app: AppHandle, + sink: Arc, server_url: String, ) -> (Arc, mpsc::UnboundedReceiver) { // rustls needs a process-wide crypto provider before the first `wss://` handshake. @@ -161,7 +161,7 @@ impl LtSession { let (sync_tx, sync_rx) = mpsc::unbounded_channel(); let inner = Inner { server_url, ..Inner::default() }; let s = Arc::new(LtSession { - app, + sink, inner: Arc::new(Mutex::new(inner)), sync_tx, gen: AtomicU64::new(0), @@ -658,11 +658,11 @@ impl LtSession { async fn emit_state(&self) { let snap = { Self::snapshot_of(&*self.inner.lock().await) }; - let _ = self.app.emit("lt-state", snap); + self.sink.emit("lt-state", snap); } async fn emit_notice(&self, msg: &str) { - let _ = self.app.emit("lt-notice", msg); + self.sink.emit("lt-notice", serde_json::json!(msg)); } } @@ -671,3 +671,18 @@ fn backoff_delay(attempt: u32) -> Duration { let shift = attempt.clamp(1, 6) - 1; // 0..=5 → 1,2,4,8,16,32 Duration::from_secs(1u64 << shift) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn emit_state_reaches_the_sink() { + use crate::host::test_support::RecordingSink; + let sink = std::sync::Arc::new(RecordingSink::default()); + let (session, _rx) = LtSession::new(sink.clone(), "wss://example.invalid".into()); + session.emit_state().await; + let events = sink.events.lock().unwrap(); + assert_eq!(events.last().map(|e| e.0), Some("lt-state")); + } +} diff --git a/src-tauri/src/local.rs b/crates/core/src/local.rs similarity index 93% rename from src-tauri/src/local.rs rename to crates/core/src/local.rs index 3f318c8..aa87cb1 100644 --- a/src-tauri/src/local.rs +++ b/crates/core/src/local.rs @@ -15,6 +15,7 @@ use lofty::file::{AudioFile, TaggedFileExt}; use lofty::tag::{Accessor, ItemKey}; use crate::db::{Db, LocalTrack}; +use crate::host::Paths; /// A local file's synthetic videoId: this prefix + the absolute path. pub const SONG_PREFIX: &str = "LOCAL:"; @@ -620,6 +621,7 @@ pub fn playback_data(video_id: &str, path: &str) -> Result Result = HashSet::new(); - for cover in songs.iter().filter_map(|s| s.thumbnail.as_deref()) { - if !seen.insert(cover) { - continue; - } - let _ = scope.allow_file(cover); - if let Ok(real) = Path::new(cover).canonicalize() { - let _ = scope.allow_file(real); - } - } -} - -/// Allow only Ryotunes-owned cover storage plus individual legacy cover files at startup. The v5 -/// rescan migrates those legacy sidecars into owned storage; watched music directories themselves -/// are never recursively exposed to the WebKit asset protocol. -pub fn allow_music_paths(app: &tauri::AppHandle, db: &Db) { - use tauri::Manager; - let scope = app.asset_protocol_scope(); - - // The only recursive renderer-visible directory is owned by Ryotunes itself. Individual - // historical sidecar covers from v2.4 are allowed just long enough for the v5 rescan to copy - // them here; the watched music directories themselves are never recursively web-visible. - let covers = covers_dir(app); - let _ = scope.allow_directory(&covers, true); - if let Ok(real) = covers.canonicalize() { - let _ = scope.allow_directory(real, true); - } - for cover in db.local_tracks(None).into_iter().filter_map(|track| track.cover) { - let _ = scope.allow_file(&cover); - if let Ok(real) = Path::new(&cover).canonicalize() { - let _ = scope.allow_file(real); - } - } -} - -/// The covers directory, alongside the SQLite file (not inside the audio cache — "Clear caches" -/// must not wipe artwork that only a full re-tag would regenerate). -pub fn covers_dir(app: &tauri::AppHandle) -> PathBuf { - use tauri::Manager; - if let Ok(dir) = app.path().app_data_dir() { - return dir.join("covers"); - } - if let Some(data) = std::env::var_os("XDG_DATA_HOME") { - return PathBuf::from(data).join("dev.ryoku.ryotunes/covers"); - } - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home).join(".local/share/dev.ryoku.ryotunes/covers"); - } - std::env::temp_dir().join("ryotunes/covers") +/// The covers directory: alongside the SQLite file (not inside the audio cache — "Clear +/// caches" must not wipe artwork that only a full re-tag would regenerate). The host resolves +/// the data dir (Tauri's `app_data_dir`, with XDG fallbacks) into [`Paths`]; the asset-protocol +/// scope that used to sit beside this now lives in the host's `local_scope` module. +pub fn covers_dir(paths: &Paths) -> PathBuf { + paths.covers_dir() } #[cfg(test)] diff --git a/src-tauri/src/lyrics.rs b/crates/core/src/lyrics.rs similarity index 94% rename from src-tauri/src/lyrics.rs rename to crates/core/src/lyrics.rs index e94ae71..4a3cfe2 100644 --- a/src-tauri/src/lyrics.rs +++ b/crates/core/src/lyrics.rs @@ -85,6 +85,13 @@ fn forced_provider() -> Option { /// Cache-through entry point for the `get_lyrics` command. pub async fn get_lyrics(state: &AppState, req: LyricsRequest) -> Option { + // Local companions/tags take precedence over remote cache entries, including cached misses. + if let Some(path) = crate::local::song_path(&req.video_id) { + let path = std::path::PathBuf::from(path); + if let Ok(Some(lyrics)) = tokio::task::spawn_blocking(move || local_lyrics(&path)).await { + return Some(lyrics); + } + } let now = now_secs(); let video_id = req.video_id.clone(); let forced = forced_provider(); @@ -101,6 +108,41 @@ pub async fn get_lyrics(state: &AppState, req: LyricsRequest) -> Option lyrics } +/// Read downloaded companions before querying providers. No network is needed for local lyrics. +fn local_lyrics(audio: &std::path::Path) -> Option { + use lofty::file::TaggedFileExt; + use lofty::tag::ItemKey; + use std::io::Read; + + fn companion(path: &std::path::Path) -> Option { + const LIMIT: u64 = 512 * 1024; + let metadata = std::fs::symlink_metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > LIMIT { + return None; + } + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(LIMIT + 1).read_to_string(&mut text).ok()?; + (text.len() as u64 <= LIMIT).then_some(text) + } + + if let Some(text) = companion(&audio.with_extension("lrc")) { + if let Some(lyrics) = from_parsed("Local LRC", parse_lrc(&text)) { + return Some(lyrics); + } + } + if let Ok(tagged) = lofty::probe::Probe::open(audio).and_then(|probe| probe.read()) { + for tag in tagged.tags() { + if let Some(lyrics) = + plain_from_text(tag.get_string(&ItemKey::Lyrics), "Embedded lyrics") + { + return Some(lyrics); + } + } + } + companion(&audio.with_extension("txt")) + .and_then(|text| plain_from_text(Some(&text), "Local lyrics")) +} + /// Run the provider chain. Second value: cache the outcome — true only when the track's duration /// was known (LRCLIB matching is loose without it and lands on wrong *cuts* of the song, lyrics /// seconds off the audio) AND some provider answered definitively (found / not-found) rather @@ -1128,6 +1170,37 @@ fn strip_xml_tags(s: &str) -> String { mod tests { use super::*; + #[test] + fn local_companions_preserve_timing_and_reject_oversized_text() { + struct Temp(std::path::PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let nonce = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let directory = Temp( + std::env::temp_dir() + .join(format!("ryotunes-local-lyrics-{}-{nonce}", std::process::id())), + ); + std::fs::create_dir(&directory.0).unwrap(); + let audio = directory.0.join("Song (1).mp3"); + std::fs::write(audio.with_extension("txt"), "Plain fallback").unwrap(); + std::fs::write(audio.with_extension("lrc"), "[00:12.34] Synced words").unwrap(); + let lyrics = local_lyrics(&audio).unwrap(); + assert!(lyrics.synced); + assert_eq!(lyrics.lines[0].time_ms, Some(12340)); + assert_eq!(lyrics.lines[0].text, "Synced words"); + std::fs::write(audio.with_extension("lrc"), "invalid timing").unwrap(); + assert_eq!(local_lyrics(&audio).unwrap().lines[0].text, "Plain fallback"); + std::fs::File::create(audio.with_extension("txt")) + .unwrap() + .set_len(512 * 1024 + 1) + .unwrap(); + assert!(local_lyrics(&audio).is_none()); + } + #[test] fn parses_basic_lrc() { let lrc = "[ar:Fleetwood Mac]\n[00:27.93] Listen to the wind blow\n[00:31.16] Watch the sun rise\n"; diff --git a/src-tauri/src/media.rs b/crates/core/src/media.rs similarity index 68% rename from src-tauri/src/media.rs rename to crates/core/src/media.rs index cdd3f0e..f912bfb 100644 --- a/src-tauri/src/media.rs +++ b/crates/core/src/media.rs @@ -2,20 +2,23 @@ //! //! `souvlaki`'s `MediaControls` isn't `Send`, and on Windows/macOS its events arrive on the //! platform's own loop — so we give it a dedicated owner thread. The app talks to that thread over -//! a channel ([`MediaHandle`]); OS control presses route back into [`AppState`] through the -//! captured `AppHandle`. The two share the same commands the UI uses, so they never drift. +//! a channel ([`MediaHandle`]); OS control presses route back out over an mpsc channel of +//! [`MediaControlEvent`]s that the host drains into `AppState`. The two share the same commands the +//! UI uses, so they never drift. use std::sync::mpsc::{channel, Sender}; use std::sync::Arc; use std::time::Duration; -use souvlaki::{ - MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, - SeekDirection, -}; -use tauri::{AppHandle, Manager}; +use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, PlatformConfig}; +use tokio::sync::mpsc::UnboundedSender; -use crate::state::AppState; +use crate::host::EventSink; + +/// Re-exported for hosts that drain [`spawn`]'s control-event channel into `AppState`. The daemon +/// pattern-matches these to route OS media-key presses, and cannot depend on `souvlaki` directly +/// without pulling a second copy of the crate; sourcing them from the core keeps one type. +pub use souvlaki::{MediaControlEvent, MediaPosition, SeekDirection}; /// Update messages: app → media-controls owner thread. enum MediaUpdate { @@ -78,10 +81,16 @@ impl MediaHandle { /// Spawn the media-controls owner thread. Returns `None` if the platform controls can't be /// created (integration simply absent then — MPRIS-only fallback is blessed, fail-soft policy). -pub fn spawn(app: AppHandle) -> Option { +/// +/// `_sink` is reserved for future media-originated events; today OS control presses travel out +/// over `commands`, which the host drains into `AppState`. +pub fn spawn( + _sink: Arc, + commands: UnboundedSender, +) -> Option { let (tx, rx) = channel::(); let spawned = - std::thread::Builder::new().name("media-controls".into()).spawn(move || run(app, rx)); + std::thread::Builder::new().name("media-controls".into()).spawn(move || run(commands, rx)); match spawned { Ok(_) => Some(MediaHandle { tx }), Err(e) => { @@ -93,14 +102,9 @@ pub fn spawn(app: AppHandle) -> Option { // `duration` is reset per track in the Metadata arm; the lint can't see the loop's later reads. #[allow(unused_assignments)] -fn run(app: AppHandle, rx: std::sync::mpsc::Receiver) { - // On Windows SMTC needs the main window handle; Linux/macOS ignore it. - #[cfg(target_os = "windows")] - let hwnd = app - .get_webview_window("main") - .and_then(|w| w.hwnd().ok()) - .map(|h| h.0 as *mut std::ffi::c_void); - #[cfg(not(target_os = "windows"))] +fn run(commands: UnboundedSender, rx: std::sync::mpsc::Receiver) { + // The core owns no window handle; on Windows the host would supply one for SMTC. Linux/MPRIS + // ignores it. let hwnd = None; let config = PlatformConfig { dbus_name: "ryotunes", display_name: "Ryotunes", hwnd }; @@ -111,8 +115,9 @@ fn run(app: AppHandle, rx: std::sync::mpsc::Receiver) { return; } }; - let cb_app = app.clone(); - if let Err(e) = controls.attach(move |event| handle_event(&cb_app, event)) { + if let Err(e) = controls.attach(move |event| { + let _ = commands.send(event); + }) { tracing::warn!(error = ?e, "media controls attach failed"); return; } @@ -186,44 +191,3 @@ fn apply_metadata( duration: duration.map(Duration::from_secs_f64), }); } - -/// Route an OS control press into the same [`AppState`] methods the UI commands use. Runs the -/// async work on the Tauri runtime (the callback fires on souvlaki's own thread, and on Windows -/// also on the UI thread via the taskbar toolbar). -pub(crate) fn handle_event(app: &AppHandle, event: MediaControlEvent) { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let Some(state) = app.try_state::>() else { return }; - let state = state.inner().clone(); - match event { - MediaControlEvent::Play => state.resume().await, - MediaControlEvent::Toggle => state.resume_or_toggle().await, - MediaControlEvent::Pause => { - let _ = state.player.pause(); - } - MediaControlEvent::Stop => { - let _ = state.player.stop(); - state.media_set_playing(false); - crate::main_window::schedule_idle_exit(&app); - } - MediaControlEvent::Next => state.next_in_queue().await, - MediaControlEvent::Previous => state.prev_in_queue().await, - MediaControlEvent::SetPosition(MediaPosition(pos)) => { - let _ = state.player.seek(pos.as_secs_f64()); - } - MediaControlEvent::SeekBy(dir, by) => { - let delta = if matches!(dir, SeekDirection::Forward) { - by.as_secs_f64() - } else { - -by.as_secs_f64() - }; - let _ = state.player.seek((state.current_position() + delta).max(0.0)); - } - MediaControlEvent::Seek(dir) => { - let delta = if matches!(dir, SeekDirection::Forward) { 10.0 } else { -10.0 }; - let _ = state.player.seek((state.current_position() + delta).max(0.0)); - } - _ => {} - } - }); -} diff --git a/src-tauri/src/orchestrator.rs b/crates/core/src/orchestrator.rs similarity index 84% rename from src-tauri/src/orchestrator.rs rename to crates/core/src/orchestrator.rs index fac1113..5fb7707 100644 --- a/src-tauri/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use innertube::{ find_format, rustypipe_fallback, AudioQuality, Clients, Format, InnerTube, PlayerResponse, @@ -25,6 +25,11 @@ pub struct PlaybackData { pub video_id: String, pub stream_url: String, pub itag: i64, + /// Extra per-file mpv options for `loadfile` (`key=value` pairs), for a stream mpv cannot + /// probe: a Spotify track arrives as raw S16LE PCM on a FIFO and needs the rawaudio demuxer + /// told so. Empty for every URL mpv can read on its own. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mpv_options: Vec, /// HTTP headers mpv must send with the stream request. #[serde(skip)] pub headers: std::collections::HashMap, @@ -56,8 +61,18 @@ pub struct PlaybackPing { pub enum ResolveError { #[error("no client could resolve a playable stream for {0}")] AllClientsFailed(String), + /// Like [`Self::AllClientsFailed`], but at least one client answered with YouTube's + /// "confirm you're not a bot" gate — the session has no (fresh) `visitorData`. Not terminal: + /// `AppState::resolve` re-bootstraps the visitorData and retries once. Never surfaces to a + /// user: the caller maps a second failure back to `AllClientsFailed`. + #[error("YouTube's bot gate rejected every playback client for {0}")] + BotGated(String), #[error("this upload could not be played. Try signing in to YouTube Music again ({0})")] UploadUnavailable(String), + /// A provider stream needs the user signed in first. The message is user-facing (the daemon + /// surfaces it straight to a toast), unlike [`Self::AllClientsFailed`] which wraps a video id. + #[error("{0}")] + SignInRequired(String), /// A local-library file that no longer exists on disk. #[error("this file is no longer on your disk: {0}")] LocalMissing(String), @@ -65,6 +80,8 @@ pub enum ResolveError { /// Client keys that need the `n`-transform applied to their stream URLs. stream selection. const NEEDS_N_TRANSFORM: [&str; 4] = ["WEB", "WEB_REMIX", "WEB_CREATOR", "TVHTML5"]; +/// Minimum spacing between two off-hot-path self-heals (`take_heal_slot`). +const HEAL_WINDOW: Duration = Duration::from_secs(10 * 60); // WEB_REMIX is validated with a HEAD like every other client — see `validate_head`. @@ -86,6 +103,11 @@ pub struct Orchestrator { /// (stream selection §2). Cleared when the cipher self-heals. `Arc` so the off-hot-path self-heal /// task can clear it. web_remix_failed: Arc>>, + /// When the last self-heal ran. A WEB_REMIX HEAD 403 is routine on capped videos (see the + /// validation comment in `resolve`), so healing on every one of them threw away player.js, + /// the cipher webview and the PoToken session per track. One heal per window is plenty to + /// catch a config that really went stale. + last_heal: Arc>>, } impl Orchestrator { @@ -101,6 +123,7 @@ impl Orchestrator { cipher, potoken, web_remix_failed: Arc::new(Mutex::new(HashSet::new())), + last_heal: Arc::new(Mutex::new(None)), } } @@ -110,6 +133,23 @@ impl Orchestrator { self.web_remix_failed.lock().await.insert(video_id.to_owned()); } + /// Forget every per-video WEB_REMIX failure. The blacklist only steers around dead cached + /// URLs; a force-clear of the caches wipes those too, so the next resolve should retry + /// WEB_REMIX rather than stay locked out for the life of the process. + pub async fn clear_web_remix_failures(&self) { + self.web_remix_failed.lock().await.clear(); + } + + /// Claim the one self-heal allowed per `HEAL_WINDOW`; false while a recent heal is cooling. + async fn take_heal_slot(&self) -> bool { + let mut last = self.last_heal.lock().await; + if last.is_some_and(|t| t.elapsed() < HEAL_WINDOW) { + return false; + } + *last = Some(Instant::now()); + true + } + /// Resolve a videoId to a playable stream. stream selection full algorithm. pub async fn resolve( &self, @@ -177,6 +217,12 @@ impl Orchestrator { // 4. Fallback loop. idx == -1 reuses the main response; 0.. are the fallback clients. let mut best: Option = None; let last_idx = order.len() as isize - 1; + // Set when any client answers the chain with the anti-bot gate. A gated chain is not a + // genuinely unplayable video: it means our anonymous session identity is missing or stale, + // which the caller can repair (fresh visitorData) and retry. + let mut bot_gated = main_resp + .as_ref() + .is_some_and(|r| !r.playability_status.is_ok() && r.playability_status.is_bot_gate()); for idx in -1..=last_idx { let (key, resp): (String, PlayerResponse) = if idx == -1 { @@ -204,6 +250,9 @@ impl Orchestrator { Ok(r) if r.playability_status.is_ok() => (key.to_owned(), r), Ok(r) => { tracing::debug!(client = key, status = %r.playability_status.status, "not OK"); + if !bot_gated && r.playability_status.is_bot_gate() { + bot_gated = true; + } continue; } Err(e) => { @@ -284,14 +333,14 @@ impl Orchestrator { main_ping.clone().or_else(|| playback_ping(&resp, &key)), is_upload, )); - } else if needs_n { + } else if needs_n && self.take_heal_slot().await { // A cipher client that fails validation may have a stale config → self-heal off // the hot path so it never blocks falling through (stream selection §7). If the heal // changes the config table, clear the WEB_REMIX failure memory (stream selection §2). let cipher = self.cipher.clone(); let potoken = self.potoken.clone(); let failed = self.web_remix_failed.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { // The session PoToken now outlives the process, so a rejected web stream is // the only signal left that Google stopped honouring it early. Drop it here // rather than replay it for the rest of its nominal 12 hours. @@ -331,6 +380,7 @@ impl Orchestrator { video_id: video_id.to_owned(), stream_url: c.url, itag: c.itag as i64, + mpv_options: Vec::new(), headers: std::collections::HashMap::new(), expires_in_seconds: c.expires_in_seconds as i64, loudness_db: c.loudness_db.map(|f| f as f64), @@ -344,7 +394,15 @@ impl Orchestrator { }), Err(e) => { tracing::error!(video_id, error = %e, "rustypipe fallback failed"); - Err(ResolveError::AllClientsFailed(video_id.to_owned())) + // Surface WHY the chain died when the reason is repairable: every InnerTube + // client answered the anti-bot gate and rustypipe found nothing either — our + // anonymous session identity (visitorData) is missing or stale, and the caller + // can bootstrap a fresh one and retry. + if bot_gated { + Err(ResolveError::BotGated(video_id.to_owned())) + } else { + Err(ResolveError::AllClientsFailed(video_id.to_owned())) + } } } } @@ -400,6 +458,7 @@ impl Orchestrator { video_id: video_id.to_owned(), stream_url: url, itag: format.itag as i64, + mpv_options: Vec::new(), headers, expires_in_seconds: expires, loudness_db: format.loudness_db.or(loudness), diff --git a/src-tauri/src/potoken/jsutil.rs b/crates/core/src/potoken/jsutil.rs similarity index 100% rename from src-tauri/src/potoken/jsutil.rs rename to crates/core/src/potoken/jsutil.rs diff --git a/src-tauri/src/potoken/mod.rs b/crates/core/src/potoken/mod.rs similarity index 93% rename from src-tauri/src/potoken/mod.rs rename to crates/core/src/potoken/mod.rs index 577a8e2..dcfbecf 100644 --- a/src-tauri/src/potoken/mod.rs +++ b/crates/core/src/potoken/mod.rs @@ -16,13 +16,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use serde_json::Value; -use tauri::AppHandle; use tokio::sync::Mutex; use tokio::time::timeout; use crate::db::{now_secs, Db}; +use crate::host::{JsBridge, JsError, JsSession}; use crate::http::WEB_UA; -use crate::webview::{Bridge, Error as WebviewError}; const GOOGLE_API_KEY: &str = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; const REQUEST_KEY: &str = "O43z0dpjhgX20SCx4KAo"; @@ -52,7 +51,7 @@ const GLUE: &str = r#"window.__lm={ struct Minter { session_id: String, expires_at: Instant, - bridge: Bridge, + bridge: Box, last_used: Instant, } @@ -89,7 +88,7 @@ impl SessionToken { } pub struct PoTokenGenerator { - app: AppHandle, + js: Arc, db: Arc, minter: Mutex>, /// Session token cache (PoToken flow: minted from visitorData, ~12h TTL). Lives OUTSIDE the @@ -102,7 +101,7 @@ pub struct PoTokenGenerator { } impl PoTokenGenerator { - pub fn new(app: AppHandle, db: Arc) -> Self { + pub fn new(js: Arc, db: Arc) -> Self { // A token stored by a previous run is as good as one minted now, right up to its expiry. // A wrong-session or expired one is simply never returned by `cached_session_token`, so it // costs nothing to load it optimistically and let the normal validity check reject it. @@ -115,7 +114,7 @@ impl PoTokenGenerator { ); } PoTokenGenerator { - app, + js, db, minter: Mutex::new(None), session_token: Mutex::new(stored), @@ -146,7 +145,7 @@ impl PoTokenGenerator { Ok(Ok(_guard)) => self.cached_session_token(visitor_data).await, Ok(Err(e)) => { tracing::warn!(error = %e, "PoToken session mint failed — degrading"); - if matches!(e, MintError::Webview(WebviewError::BadWebview(_))) { + if matches!(e, MintError::Webview(JsError::BadEnvironment(_))) { self.webview_bad.store(true, Ordering::SeqCst); } self.teardown().await; @@ -174,7 +173,7 @@ impl PoTokenGenerator { Ok(Ok(pot)) => Some(pot), Ok(Err(e)) => { tracing::warn!(video_id, error = %e, "PoToken streaming mint failed — degrading"); - if matches!(e, MintError::Webview(WebviewError::BadWebview(_))) { + if matches!(e, MintError::Webview(JsError::BadEnvironment(_))) { self.webview_bad.store(true, Ordering::SeqCst); } self.teardown().await; @@ -209,7 +208,7 @@ impl PoTokenGenerator { let mut guard = self.minter.lock().await; if !guard.as_ref().is_some_and(|m| m.valid_for(visitor_data)) { if let Some(old) = guard.take() { - let _ = old.bridge.destroy(); + old.bridge.destroy(); } *guard = Some(self.create_minter(visitor_data).await?); } @@ -225,14 +224,14 @@ impl PoTokenGenerator { let mut guard = self.ensure_minter(visitor_data).await?; let minter = guard.as_mut().expect("minter present"); minter.last_used = Instant::now(); - let bridge = minter.bridge.clone(); - match mint_token(&bridge, video_id.as_bytes()).await { + let bridge = minter.bridge.clone_session(); + match mint_token(&*bridge, video_id.as_bytes()).await { Ok(pot) => Ok(pot), Err(e) => { tracing::debug!(error = %e, "per-video mint failed, rebuilding minter once"); - let _ = bridge.destroy(); + bridge.destroy(); let fresh = self.create_minter(visitor_data).await?; - let pot = mint_token(&fresh.bridge, video_id.as_bytes()).await?; + let pot = mint_token(&*fresh.bridge, video_id.as_bytes()).await?; *guard = Some(fresh); Ok(pot) } @@ -241,11 +240,11 @@ impl PoTokenGenerator { /// Full BotGuard bootstrap: Create → runBotGuard → GenerateIT → createMinter → session token. async fn create_minter(&self, session_id: &str) -> Result { - let bridge = Bridge::create(&self.app, POTOKEN_LABEL, HARNESS, GLUE).await?; - match self.bootstrap_minter(&bridge, session_id).await { + let bridge = self.js.create(POTOKEN_LABEL, HARNESS, GLUE).await?; + match self.bootstrap_minter(&*bridge, session_id).await { Ok(m) => Ok(m), Err(e) => { - let _ = bridge.destroy(); // don't orphan the hidden window on a failed bootstrap + bridge.destroy(); // don't orphan the hidden window on a failed bootstrap Err(e) } } @@ -255,7 +254,7 @@ impl PoTokenGenerator { /// `create_minter` can destroy the webview on any error path. async fn bootstrap_minter( &self, - bridge: &Bridge, + bridge: &dyn JsSession, session_id: &str, ) -> Result { // 1. /Create → descrambled challengeData for runBotGuard. @@ -303,7 +302,7 @@ impl PoTokenGenerator { Ok(Minter { session_id: session_id.to_owned(), expires_at: Instant::now() + good_for, - bridge: bridge.clone(), + bridge: bridge.clone_session(), last_used: Instant::now(), }) } @@ -351,7 +350,7 @@ impl PoTokenGenerator { let mut guard = self.minter.lock().await; if let Some(m) = guard.as_ref() { if m.last_used.elapsed() >= idle { - let _ = m.bridge.destroy(); + m.bridge.destroy(); *guard = None; tracing::debug!("PoToken webview torn down (idle)"); } @@ -360,16 +359,16 @@ impl PoTokenGenerator { async fn teardown(&self) { if let Some(m) = self.minter.lock().await.take() { - let _ = m.bridge.destroy(); + m.bridge.destroy(); } // A failed/cancelled create_minter (or a mint timeout that cancelled us mid-bootstrap) // leaves an untracked window on our label — reclaim it so no hidden webview is orphaned. - crate::webview::destroy_and_wait(&self.app, POTOKEN_LABEL).await; + self.js.reclaim(POTOKEN_LABEL).await; } } /// [webview] obtain one PoToken for `identifier` (raw UTF-8 bytes) → URL-safe base64. -async fn mint_token(bridge: &Bridge, identifier: &[u8]) -> Result { +async fn mint_token(bridge: &dyn JsSession, identifier: &[u8]) -> Result { let arr = bridge .call_async(&format!("__lm.mint({})", jsutil::js_byte_array(identifier)), CALL_TIMEOUT) .await?; @@ -388,7 +387,7 @@ async fn mint_token(bridge: &Bridge, identifier: &[u8]) -> Result PlaybackData { video_id: format!("{RADIO_ID_PREFIX}{}", station.station_uuid), stream_url: station.stream_url.clone(), itag: 0, + mpv_options: Vec::new(), headers: Default::default(), expires_in_seconds: 0, loudness_db: None, diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs new file mode 100644 index 0000000..4bcdecb --- /dev/null +++ b/crates/core/src/session.rs @@ -0,0 +1,75 @@ +//! Login session logic shared across hosts: the navigation allow-list the interactive sign-in +//! window enforces, and the orchestration that applies the host's [`LoginResult`]. +//! +//! The visible Google sign-in window itself lives in the host (`login_webview.rs`), because it +//! needs a real browser surface; it hands its harvested cookies back through +//! [`crate::host::LoginFlow`] and this module applies them through the same cookie path as before. + +use std::sync::Arc; + +use crate::host::{LoginError, LoginResult}; +use crate::state::{AppState, SignInOutcome}; + +/// Google sign-in with `continue` back to YTM, so a successful login redirects to +/// music.youtube.com (the host's completion signal). Kept here so every host opens the same URL. +pub const LOGIN_URL: &str = + "https://accounts.google.com/ServiceLogin?service=youtube&continue=https://music.youtube.com/"; + +/// The navigation allow-list the login window enforces: only https on Google/YouTube. Shared with +/// the host so a phishing redirect can never keep the sign-in window. +pub fn allowed_login_navigation(url: &url::Url) -> bool { + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { return false }; + host == "google.com" + || host.ends_with(".google.com") + || host == "youtube.com" + || host.ends_with(".youtube.com") +} + +impl AppState { + /// Run the host's interactive sign-in and apply the result exactly as the old login webview + /// did: the host harvests the cookies, then they flow through the same cookie path, emitting + /// `login-done` / `account-selection-required` / `login-error`. Sign-in completes + /// asynchronously; the UI learns via those events. + pub async fn sign_in(self: &Arc) { + match self.login.sign_in().await { + Ok(result) => self.apply_login(result).await, + // A closed window is a silent no-op, matching the old fire-and-forget flow. + Err(LoginError::Cancelled) => {} + Err(e) => self.emit("login-error", e.to_string()), + } + } + + async fn apply_login(&self, result: LoginResult) { + // The host hands back the `.youtube.com` jar as `name=value` pairs; rebuild the Cookie + // header the cookie path expects (sorted, so it is byte-identical to the old flow). + let header = + result.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("; "); + match self.apply_cookie(header).await { + Ok(SignInOutcome::Complete) => self.emit("login-done", ()), + // The authenticated cookie is saved, but the account stays deliberately unfinished + // until the main-window picker selects a server-issued delegated identity. + Ok(SignInOutcome::SelectionRequired) => {} + Err(e) => self.emit("login-error", e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn login_navigation_stays_on_google_and_youtube_https() { + assert!(allowed_login_navigation( + &url::Url::parse("https://accounts.google.com/ServiceLogin").unwrap() + )); + assert!(allowed_login_navigation(&url::Url::parse("https://music.youtube.com/").unwrap())); + assert!(!allowed_login_navigation( + &url::Url::parse("http://accounts.google.com/").unwrap() + )); + assert!(!allowed_login_navigation(&url::Url::parse("https://example.com/phish").unwrap())); + } +} diff --git a/crates/core/src/soundcloud_bridge.rs b/crates/core/src/soundcloud_bridge.rs new file mode 100644 index 0000000..7fb54ca --- /dev/null +++ b/crates/core/src/soundcloud_bridge.rs @@ -0,0 +1,607 @@ +//! Pure mapping from the SoundCloud provider's models ([`ryotunes_soundcloud`]) to the +//! YouTube-shaped browse JSON the client already renders — the SoundCloud twin of +//! [`crate::spotify_bridge`]. The daemon calls these when the selected provider is SoundCloud +//! (browsing) or an id carries an `sc:` prefix (a track/user/playlist page). +//! +//! Ids are `sc:track:` / `sc:user:` / `sc:playlist:` (albums are just playlists on +//! SoundCloud); the crate uses bare numeric ids, so these functions add the prefix on the way out +//! (the daemon strips it on the way back in). Durations render "m:ss" (or "h:mm:ss" past an hour), +//! play counts abbreviated the way YouTube writes them ("547M"). Cards and song rows are built as +//! the real [`innertube`] structs and serialised through them, so serde field naming stays +//! byte-identical to the YouTube surfaces. +//! +//! Every page reuses the YouTube page structs verbatim except the artist page, which is a +//! SoundCloud-specific superset ([`SoundcloudArtistPage`]): the Orange artist view (round avatar, +//! banner, followers, and the albums/playlists/likes side columns) needs fields the YouTube +//! [`innertube::ArtistPage`] has no room for. +//! +//! These are pure: no network, no `AppState`, no async. The daemon fetches the crate models; this +//! turns them into the client's shapes. + +use innertube::models::metadata::ArtistRun; +use innertube::{AlbumPage, BrowseItem, HomePage, PlaylistPage, SearchResults, Section, SongItem}; +use ryotunes_soundcloud::{ + DiscoverItem, Playlist, PlaylistDetail, SearchResults as ScResults, Selection, SystemPlaylist, + Track, User, UserRef, +}; + +const TRACK: &str = "sc:track:"; +const USER: &str = "sc:user:"; +const PLAYLIST: &str = "sc:playlist:"; +const SYSTEM: &str = "sc:system:"; + +// --- tracks ---------------------------------------------------------------------------------- + +/// A SoundCloud [`Track`] as a queue/playlist [`SongItem`]. The artist is the uploading user +/// (SoundCloud has no separate album artist), the thumbnail is the track art falling back to the +/// uploader's avatar, and `play_count` carries the play total abbreviated the way YouTube writes +/// it. `rating` is `None` (a guest cannot like), `album`/`is_upload`/`is_video`/`explicit` are all +/// empty/false — SoundCloud has none of them. +pub fn track_to_song(track: &Track) -> SongItem { + SongItem { + video_id: format!("{TRACK}{}", track.id), + title: track.title.clone(), + artists: track.user.username.clone(), + artist_id: Some(format!("{USER}{}", track.user.id)), + artist_runs: user_ref_runs(&track.user), + album: None, + album_id: None, + duration: Some(fmt_duration_ms(track.duration_ms)), + play_count: track.plays.map(abbreviate), + thumbnail: track_art(track), + set_video_id: None, + added_by: None, + added_by_avatar: None, + rating: None, + queued_by: None, + queued: false, + queued_end: false, + queued_from: None, + autoplay: false, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A SoundCloud [`Track`] as a `song` [`BrowseItem`] (a home/search card). +pub fn track_to_card(track: &Track) -> BrowseItem { + BrowseItem { + kind: "song", + id: format!("{TRACK}{}", track.id), + title: track.title.clone(), + subtitle: Some(track.user.username.clone()), + thumbnail: track_art(track), + duration: Some(fmt_duration_ms(track.duration_ms)), + artist_runs: user_ref_runs(&track.user), + play_count: track.plays.map(abbreviate), + is_video: false, + is_upload: false, + explicit: false, + } +} + +// --- cards ----------------------------------------------------------------------------------- + +/// A SoundCloud [`User`] as an `artist` [`BrowseItem`]. +pub fn user_to_card(user: &User) -> BrowseItem { + BrowseItem { + kind: "artist", + id: format!("{USER}{}", user.id), + title: user.username.clone(), + subtitle: None, + thumbnail: user.avatar.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A SoundCloud [`Playlist`] with `is_album` as an `album` [`BrowseItem`]; the subtitle reads +/// "Album · ". +pub fn album_to_card(pl: &Playlist) -> BrowseItem { + BrowseItem { + kind: "album", + id: format!("{PLAYLIST}{}", pl.id), + title: pl.title.clone(), + subtitle: Some(album_line(pl)), + thumbnail: pl.artwork.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A SoundCloud [`Playlist`] (not an album) as a `playlist` [`BrowseItem`]; the subtitle reads +/// " · N tracks". +pub fn playlist_to_card(pl: &Playlist) -> BrowseItem { + BrowseItem { + kind: "playlist", + id: format!("{PLAYLIST}{}", pl.id), + title: pl.title.clone(), + subtitle: Some(playlist_subtitle(pl)), + thumbnail: pl.artwork.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +// --- pages ----------------------------------------------------------------------------------- + +/// A SoundCloud album ([`PlaylistDetail`] whose playlist is `is_album`) as the album page. The +/// `artist_id`/`playlist_id` carry `sc:` ids so the artist link and any downstream navigation +/// round-trip back through the daemon. +pub fn album_page(detail: &PlaylistDetail) -> AlbumPage { + let pl = &detail.playlist; + let count = detail.tracks.len().max(pl.track_count as usize) as u64; + AlbumPage { + title: Some(pl.title.clone()), + artist: Some(pl.user.username.clone()), + artist_id: Some(format!("{USER}{}", pl.user.id)), + artist_runs: user_ref_runs(&pl.user), + artist_thumbnail: pl.user.avatar.clone(), + subtitle: Some(album_line(pl)), + second_subtitle: Some(track_count_line(count)), + description: detail.description.clone(), + thumbnail: pl.artwork.clone(), + items: detail.tracks.iter().map(track_to_song).collect(), + continuation: None, + explicit: false, + playlist_id: Some(format!("{PLAYLIST}{}", pl.id)), + in_library: false, + sections: Vec::new(), + } +} + +/// A SoundCloud [`PlaylistDetail`] as the playlist page. +pub fn playlist_page(detail: &PlaylistDetail) -> PlaylistPage { + let pl = &detail.playlist; + PlaylistPage { + title: Some(pl.title.clone()), + subtitle: Some(pl.user.username.clone()), + thumbnail: pl.artwork.clone(), + description: detail.description.clone(), + privacy: None, + cover: None, + items: detail.tracks.iter().map(track_to_song).collect(), + continuation: None, + owned: false, + collaborative: false, + sort_menu: None, + } +} + +/// The SoundCloud home: one shelf per discover [`Selection`], in the order SoundCloud returns them +/// ("Trending by genre", "Curated by SoundCloud", "Artists to watch out for", …). Empty shelves +/// are dropped so a partial fetch never leaves a blank row. +pub fn discover_home(selections: &[Selection]) -> HomePage { + let sections = selections + .iter() + .filter_map(|sel| { + let items: Vec = sel.items.iter().map(discover_item_to_card).collect(); + (!items.is_empty()).then(|| card_section(sel.title.clone(), items)) + }) + .collect(); + HomePage { chips: Vec::new(), sections, continuation: None } +} + +/// The signed-in user's own shelves, first on Home: their playlists, their liked tracks, and +/// the artists they follow. Empty lists drop their shelf, so a partial fetch (or a fresh +/// account) never renders a blank row. +pub fn personal_home(playlists: &[Playlist], likes: &[Track], followings: &[User]) -> Vec
{ + let mut sections = Vec::new(); + if !playlists.is_empty() { + sections.push(card_section( + "Your playlists".to_string(), + playlists.iter().map(playlist_to_card).collect(), + )); + } + if !likes.is_empty() { + sections.push(card_section( + "Liked tracks".to_string(), + likes.iter().map(track_to_card).collect(), + )); + } + if !followings.is_empty() { + sections.push(card_section( + "Following".to_string(), + followings.iter().map(user_to_card).collect(), + )); + } + sections +} + +/// One discover shelf entry as a card: a regular playlist, or a SoundCloud system playlist (a +/// curated/charts list keyed by permalink). +fn discover_item_to_card(item: &DiscoverItem) -> BrowseItem { + match item { + DiscoverItem::Playlist(pl) => playlist_to_card(pl), + DiscoverItem::System(sp) => system_to_card(sp), + } +} + +/// A SoundCloud [`SystemPlaylist`] as a `playlist` [`BrowseItem`], id `sc:system:`. The +/// title is the short title and the subtitle reads "Trending · N tracks". +pub fn system_to_card(sp: &SystemPlaylist) -> BrowseItem { + BrowseItem { + kind: "playlist", + id: format!("{SYSTEM}{}", sp.permalink), + title: sp.short_title.clone(), + subtitle: Some(format!("Trending · {}", track_count_line(sp.track_count))), + thumbnail: sp.artwork.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A SoundCloud system playlist ([`PlaylistDetail`] from `system_playlist`) as the playlist page. +/// System playlists have no owner, so the subtitle reads "SoundCloud". +pub fn system_playlist_page(detail: &PlaylistDetail) -> PlaylistPage { + let pl = &detail.playlist; + PlaylistPage { + title: Some(pl.title.clone()), + subtitle: Some("SoundCloud".to_owned()), + thumbnail: pl.artwork.clone(), + description: detail.description.clone(), + privacy: None, + cover: None, + items: detail.tracks.iter().map(track_to_song).collect(), + continuation: None, + owned: false, + collaborative: false, + sort_menu: None, + } +} + +/// SoundCloud [`ScResults`] as the `search_all` payload. SoundCloud has no "top result" shelf, so +/// `top` is empty; albums are the `is_album` playlists, playlists the rest. +pub fn search_all(results: &ScResults) -> SearchResults { + SearchResults { + top: Vec::new(), + songs: results.tracks.iter().map(track_to_card).collect(), + albums: results.playlists.iter().filter(|p| p.is_album).map(album_to_card).collect(), + artists: results.users.iter().map(user_to_card).collect(), + playlists: results.playlists.iter().filter(|p| !p.is_album).map(playlist_to_card).collect(), + continuation: None, + } +} + +/// One `search_cards` category (`songs`|`albums`|`artists`|`playlists`) as a flat card list. +pub fn search_cards(results: &ScResults, category: &str) -> Vec { + match category { + "songs" => results.tracks.iter().map(track_to_card).collect(), + "albums" => results.playlists.iter().filter(|p| p.is_album).map(album_to_card).collect(), + "artists" => results.users.iter().map(user_to_card).collect(), + "playlists" => { + results.playlists.iter().filter(|p| !p.is_album).map(playlist_to_card).collect() + } + _ => Vec::new(), + } +} + +/// The `search_page` items: the track hits as song rows. +pub fn search_songs(results: &ScResults) -> Vec { + results.tracks.iter().map(track_to_song).collect() +} + +// --- artist page ----------------------------------------------------------------------------- + +/// The Orange/SoundCloud artist page. A superset of the YouTube [`innertube::ArtistPage`]: the +/// user's card fields (round avatar, banner, follower/track counts, verified tick) plus the four +/// content columns the view renders — the user's `tracks`, their `albums`, `playlists`, and +/// `likes`. `kind` is always `"soundcloud"` so the client switches to the bespoke view. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SoundcloudArtistPage { + pub kind: &'static str, + /// `sc:user:` — the id the page navigated to. + pub channel_id: String, + pub name: String, + /// The round avatar. + pub thumbnail: Option, + /// The wide profile banner, blurred to paper behind the header. + pub banner: Option, + pub followers: u64, + pub track_count: u64, + pub city: Option, + pub country: Option, + pub description: Option, + pub verified: bool, + /// The user's own tracks (the left column list). + pub tracks: Vec, + /// "Albums from this user". + pub albums: Vec, + pub playlists: Vec, + pub likes: Vec, +} + +/// Build the [`SoundcloudArtistPage`] from a resolved user and their four content lists. +pub fn artist_page( + user: &User, + tracks: &[Track], + albums: &[Playlist], + playlists: &[Playlist], + likes: &[Track], +) -> SoundcloudArtistPage { + SoundcloudArtistPage { + kind: "soundcloud", + channel_id: format!("{USER}{}", user.id), + name: user.username.clone(), + thumbnail: user.avatar.clone(), + banner: user.banner.clone(), + followers: user.followers, + track_count: user.track_count, + city: user.city.clone(), + country: user.country.clone(), + description: user.description.clone(), + verified: user.verified, + tracks: tracks.iter().map(track_to_song).collect(), + albums: albums.iter().map(album_to_card).collect(), + playlists: playlists.iter().map(playlist_to_card).collect(), + likes: likes.iter().map(track_to_song).collect(), + } +} + +// --- helpers --------------------------------------------------------------------------------- + +fn card_section(title: String, items: Vec) -> Section { + Section { title, title_is_artist: false, items, more_browse_id: None, more_params: None } +} + +fn user_ref_runs(user: &UserRef) -> Vec { + vec![ArtistRun { text: user.username.clone(), id: Some(format!("{USER}{}", user.id)) }] +} + +fn track_art(track: &Track) -> Option { + track.artwork.clone().or_else(|| track.user.avatar.clone()) +} + +fn album_line(pl: &Playlist) -> String { + match pl.release_date.as_deref().and_then(year_of) { + Some(year) => format!("Album · {year}"), + None => "Album".to_owned(), + } +} + +fn track_count_line(n: u64) -> String { + format!("{n} track{}", if n == 1 { "" } else { "s" }) +} + +/// " · N tracks" for a playlist card, or just "N tracks" when the playlist has no owner. +fn playlist_subtitle(pl: &Playlist) -> String { + if pl.user.username.is_empty() { + track_count_line(pl.track_count) + } else { + format!("{} · {}", pl.user.username, track_count_line(pl.track_count)) + } +} + +/// The leading four-digit year of a SoundCloud date string (`2019-05-10T00:00:00Z`), if it starts +/// with one. +fn year_of(date: &str) -> Option<&str> { + let year = date.get(0..4)?; + year.chars().all(|c| c.is_ascii_digit()).then_some(year) +} + +/// "m:ss", or "h:mm:ss" once the track runs an hour or more. +pub(crate) fn fmt_duration_ms(ms: u64) -> String { + let total = ms / 1000; + let (hours, minutes, seconds) = (total / 3600, (total % 3600) / 60, total % 60); + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes}:{seconds:02}") + } +} + +/// A play/like count the way YouTube abbreviates it: "547M", "2.5B", "1.2K", "999". +pub(crate) fn abbreviate(n: u64) -> String { + fn scale(value: f64, unit: &str) -> String { + if value >= 10.0 { + format!("{}{unit}", value.round() as u64) + } else { + let text = format!("{value:.1}"); + format!("{}{unit}", text.strip_suffix(".0").unwrap_or(&text)) + } + } + if n >= 1_000_000_000 { + scale(n as f64 / 1e9, "B") + } else if n >= 1_000_000 { + scale(n as f64 / 1e6, "M") + } else if n >= 1_000 { + scale(n as f64 / 1e3, "K") + } else { + n.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ryotunes_soundcloud::UserRef; + + fn track(id: u64) -> Track { + Track { + id, + title: "Jubel".to_owned(), + user: UserRef { + id: 42, + username: "Klingande".to_owned(), + permalink: "klingande".to_owned(), + avatar: Some("https://cdn/avatar-large.jpg".to_owned()), + }, + duration_ms: 425_000, + artwork: Some("https://cdn/art-t500x500.jpg".to_owned()), + genre: Some("Deep House".to_owned()), + tags: vec![], + plays: Some(12_500_000), + likes: Some(210_000), + created_at: "2013-09-01T00:00:00Z".to_owned(), + permalink_url: "https://soundcloud.com/klingande/jubel".to_owned(), + waveform_url: Some("https://wave/j.json".to_owned()), + authorization: Some("tok".to_owned()), + transcodings: vec![], + description: None, + } + } + + #[test] + fn track_maps_to_prefixed_song_row() { + let s = track_to_song(&track(580_008_897)); + assert_eq!(s.video_id, "sc:track:580008897"); + assert_eq!(s.artists, "Klingande"); + assert_eq!(s.artist_id.as_deref(), Some("sc:user:42")); + assert_eq!(s.duration.as_deref(), Some("7:05")); + assert_eq!(s.play_count.as_deref(), Some("13M")); + assert_eq!(s.thumbnail.as_deref(), Some("https://cdn/art-t500x500.jpg")); + assert!(s.rating.is_none()); + assert!(!s.is_upload && !s.is_video && !s.explicit); + } + + #[test] + fn artist_page_carries_soundcloud_keys() { + let user = User { + id: 42, + username: "Klingande".to_owned(), + full_name: None, + permalink: "klingande".to_owned(), + avatar: Some("https://cdn/avatar-large.jpg".to_owned()), + banner: Some("https://cdn/banner.jpg".to_owned()), + followers: 500_000, + track_count: 20, + city: Some("Paris".to_owned()), + country: Some("France".to_owned()), + description: Some("bio".to_owned()), + verified: true, + }; + let album = Playlist { + id: 7, + title: "The Album".to_owned(), + user: user_ref(), + artwork: Some("https://cdn/al.jpg".to_owned()), + is_album: true, + set_type: Some("album".to_owned()), + track_count: 10, + release_date: Some("2016-04-22T00:00:00Z".to_owned()), + duration_ms: 2_400_000, + }; + let page = artist_page(&user, &[track(1)], &[album], &[], &[track(2)]); + let json = serde_json::to_value(&page).unwrap(); + let obj = json.as_object().unwrap(); + for key in [ + "kind", + "channelId", + "name", + "thumbnail", + "banner", + "followers", + "trackCount", + "city", + "country", + "description", + "verified", + "tracks", + "albums", + "playlists", + "likes", + ] { + assert!(obj.contains_key(key), "artist_page missing key {key}"); + } + assert_eq!(obj["kind"], "soundcloud"); + assert_eq!(obj["channelId"], "sc:user:42"); + assert_eq!(obj["albums"][0]["subtitle"], "Album · 2016"); + assert_eq!(obj["albums"][0]["id"], "sc:playlist:7"); + } + + fn user_ref() -> UserRef { + UserRef { + id: 42, + username: "Klingande".to_owned(), + permalink: "klingande".to_owned(), + avatar: None, + } + } + + #[test] + fn albums_and_playlists_split_by_is_album() { + let mk = |id: u64, is_album: bool| Playlist { + id, + title: format!("p{id}"), + user: user_ref(), + artwork: None, + is_album, + set_type: None, + track_count: 3, + release_date: None, + duration_ms: 0, + }; + let results = + ScResults { tracks: vec![], users: vec![], playlists: vec![mk(1, true), mk(2, false)] }; + let all = search_all(&results); + assert_eq!(all.albums.len(), 1); + assert_eq!(all.albums[0].kind, "album"); + assert_eq!(all.playlists.len(), 1); + assert_eq!(all.playlists[0].kind, "playlist"); + } + + #[test] + fn discover_home_maps_system_and_playlist_shelves() { + let sys = SystemPlaylist { + permalink: "trending-by-genre:trap".to_owned(), + urn: "soundcloud:system-playlists:trending-by-genre:trap".to_owned(), + title: "Trending: Trap".to_owned(), + short_title: "Trap".to_owned(), + description: None, + artwork: Some("https://cdn/trap.jpg".to_owned()), + track_count: 49, + }; + let pl = Playlist { + id: 9, + title: "Fresh Finds".to_owned(), + user: user_ref(), + artwork: None, + is_album: false, + set_type: None, + track_count: 12, + release_date: None, + duration_ms: 0, + }; + let selections = vec![ + Selection { + slug: "trending-by-genre".to_owned(), + title: "Trending by genre".to_owned(), + items: vec![DiscoverItem::System(sys)], + }, + Selection { + slug: "artists-to-watch".to_owned(), + title: "Artists to watch out for".to_owned(), + items: vec![DiscoverItem::Playlist(pl)], + }, + ]; + let home = discover_home(&selections); + assert_eq!(home.sections.len(), 2); + assert_eq!(home.sections[0].title, "Trending by genre"); + let sys_card = &home.sections[0].items[0]; + assert_eq!(sys_card.kind, "playlist"); + assert_eq!(sys_card.id, "sc:system:trending-by-genre:trap"); + assert_eq!(sys_card.title, "Trap"); + assert_eq!(sys_card.subtitle.as_deref(), Some("Trending · 49 tracks")); + let pl_card = &home.sections[1].items[0]; + assert_eq!(pl_card.id, "sc:playlist:9"); + assert_eq!(pl_card.subtitle.as_deref(), Some("Klingande · 12 tracks")); + } +} diff --git a/crates/core/src/spotify.rs b/crates/core/src/spotify.rs new file mode 100644 index 0000000..e19c951 --- /dev/null +++ b/crates/core/src/spotify.rs @@ -0,0 +1,244 @@ +//! Spotify provider state, held for the daemon. +//! +//! A thin wrapper around [`ryotunes_spotify::SpotifyProvider`] plus the currently signed-in +//! [`ryotunes_spotify::Client`], behind a [`tokio::sync::RwLock`] so many readers (playback, +//! browsing) share one client. The daemon adds the actual command methods on top of this (see the +//! `## Daemon wiring` section of `crates/spotify/README.md`); this file deliberately wires none. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use ryotunes_spotify::{Client, SpotifyProvider}; +use tokio::sync::RwLock; + +/// Which catalogue the browsing commands (search, home, library, album, artist, playlist) read +/// from. YouTube Music and Spotify are peers: the queue may hold tracks from both, told apart by +/// their ids (`spotify:track:…` vs a video id), and playback of either goes through the one mpv. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + Youtube, + Spotify, + Soundcloud, +} + +impl Provider { + pub fn as_str(self) -> &'static str { + match self { + Provider::Youtube => "youtube", + Provider::Spotify => "spotify", + Provider::Soundcloud => "soundcloud", + } + } + pub fn parse(s: &str) -> Option { + match s { + "youtube" => Some(Provider::Youtube), + "spotify" => Some(Provider::Spotify), + "soundcloud" => Some(Provider::Soundcloud), + _ => None, + } + } +} + +pub const SPOTIFY_TRACK_PREFIX: &str = "spotify:track:"; + +/// True for any id the daemon minted for a Spotify item (`spotify::`). +pub fn is_spotify_id(id: &str) -> bool { + id.starts_with("spotify:") +} + +/// The bare base62 id of a Spotify track id, or None for anything else. +pub fn spotify_track_id(id: &str) -> Option<&str> { + id.strip_prefix(SPOTIFY_TRACK_PREFIX).filter(|s| !s.is_empty()) +} + +pub const SC_TRACK_PREFIX: &str = "sc:track:"; +pub const SC_USER_PREFIX: &str = "sc:user:"; +pub const SC_PLAYLIST_PREFIX: &str = "sc:playlist:"; +pub const SC_SYSTEM_PREFIX: &str = "sc:system:"; + +/// True for any id the daemon minted for a SoundCloud item (`sc::`). SoundCloud tracks +/// stream over plain HTTPS (an HLS m3u8), not the Spotify FIFO — see `AppState::resolve`. +pub fn is_sc_id(id: &str) -> bool { + id.starts_with("sc:") +} + +/// The numeric id of a SoundCloud track id (`sc:track:`), or None for anything else. +pub fn sc_track_id(id: &str) -> Option { + id.strip_prefix(SC_TRACK_PREFIX)?.parse().ok() +} + +/// The numeric id of a SoundCloud user id (`sc:user:`). +pub fn sc_user_id(id: &str) -> Option { + id.strip_prefix(SC_USER_PREFIX)?.parse().ok() +} + +/// The numeric id of a SoundCloud playlist/album id (`sc:playlist:`). +pub fn sc_playlist_id(id: &str) -> Option { + id.strip_prefix(SC_PLAYLIST_PREFIX)?.parse().ok() +} + +/// The permalink of a SoundCloud system-playlist id (`sc:system:`) — a curated/charts +/// playlist keyed by permalink rather than a numeric id. `None` for anything else. +pub fn sc_system_id(id: &str) -> Option<&str> { + id.strip_prefix(SC_SYSTEM_PREFIX).filter(|s| !s.is_empty()) +} + +/// Owns the Spotify sign-in, the current client and the catalogue selector. Construct once with the +/// daemon's data directory; `spotify` credentials land under `/spotify`. +pub struct SpotifyState { + provider: SpotifyProvider, + client: RwLock>>, + /// The one on-demand session recovery this process is allowed (see `client_or_recover`). + /// Reset by a fresh sign-in or sign-out, which start a new credential lifetime. + recovery_used: std::sync::atomic::AtomicBool, + /// Serialises session restores so an on-demand recovery never runs a second librespot + /// connect against the same cached credentials while the startup restore is mid-flight. + recover_lock: tokio::sync::Mutex<()>, + selected: parking_lot::RwLock, +} + +impl SpotifyState { + pub fn new(data_dir: PathBuf, selected: Provider) -> Self { + Self { + provider: SpotifyProvider::new(data_dir), + client: RwLock::new(None), + recovery_used: std::sync::atomic::AtomicBool::new(false), + recover_lock: tokio::sync::Mutex::new(()), + selected: parking_lot::RwLock::new(selected), + } + } + + /// The catalogue the browsing commands read from right now. + pub fn selected(&self) -> Provider { + *self.selected.read() + } + + pub fn select(&self, p: Provider) { + *self.selected.write() = p; + } + + /// Whether browsing should go to Spotify: selected AND signed in. Selected without a session + /// is the sign-in state the client shows, not a catalogue. + pub async fn browsing_spotify(&self) -> bool { + self.selected() == Provider::Spotify && self.status().await + } + + /// Whether browsing should go to SoundCloud. SoundCloud is guest-only (no account), so this + /// is just the selection — there is no sign-in gate like [`Self::browsing_spotify`]. + pub fn browsing_soundcloud(&self) -> bool { + self.selected() == Provider::Soundcloud + } + + /// Whether a credential file exists to restore from (no network). + pub fn stored(&self) -> bool { + self.provider.stored() + } + + /// Whether a usable client exists. A session librespot invalidated (network drop, Spotify + /// killed it server-side) is *not* usable: it is dropped here so a later `restore`/ + /// `client_or_recover` sees the truth and announces the sign-out to the daemon's event sink. + pub async fn status(&self) -> bool { + self.live_client().await.is_some() + } + + /// The signed-in client, cloned out for use without holding the lock. A dead session reads as + /// signed out (and is dropped), so callers never hand a doomed client to librespot. + pub async fn client(&self) -> Option> { + self.live_client().await + } + + /// A session that is both present and still connected; drops (and logs) one that died. + async fn live_client(&self) -> Option> { + let live = { + let guard = self.client.read().await; + match guard.as_ref() { + Some(c) if c.alive() => Some(Arc::clone(c)), + Some(_) => None, + None => return None, + } + }; + if live.is_none() { + // Drops a session librespot invalidated; `restore_locked` serialises actual + // (re)connects so this never races a connect in flight. + let dead = self.client.write().await.take(); + if dead.is_some() { + tracing::warn!("spotify: the session was invalidated; treating as signed out"); + } + } + live + } + + /// The client for playback/browsing, recovering a dead or missing one from the saved + /// credentials the first time this process needs it. This is the fix for "every song is + /// skipped as unavailable, and it asks me to sign in every time I open it": a startup + /// restore that raced with server bind, or a session that died mid-life, used to leave the + /// daemon permanently signed-out for that run. Recovery is attempted exactly once per + /// credential lifetime so a genuinely dead credential pair cannot stall every track start. + pub async fn client_or_recover(&self) -> Option> { + if let Some(client) = self.live_client().await { + return Some(client); + } + if !self.provider.stored() + || self.recovery_used.swap(true, std::sync::atomic::Ordering::Relaxed) + { + return None; + } + // `restore` serializes against the startup restore itself. + match self.restore().await { + Ok(true) => { + tracing::info!("spotify: recovered the cached session on demand"); + self.live_client().await + } + Ok(false) => None, + Err(e) => { + tracing::warn!(error = %e, "spotify: on-demand session recovery failed"); + None + } + } + } + + /// Restore a client from cached credentials. Returns whether one was restored (or was + /// already live). Serialized on `recover_lock`: a startup restore still connecting and an + /// on-demand recovery racing over one credential cache is how a good session gets + /// invalidated — and under the lock the restore we waited for may already have landed, in + /// which case this simply returns true. + pub async fn restore(&self) -> Result { + let _guard = self.recover_lock.lock().await; + self.restore_locked().await + } + + /// The caller holds `recover_lock`. + async fn restore_locked(&self) -> Result { + if self.live_client().await.is_some() { + return Ok(true); + } + match self.provider.restore().await? { + Some(client) => { + *self.client.write().await = Some(Arc::new(client)); + Ok(true) + } + None => Ok(false), + } + } + + /// Run the OAuth flow. `on_url` receives the authorization URL to hand to the client; on success + /// the resulting client is stored, and a fresh credential lifetime arms recovery again. + pub async fn sign_in(&self, on_url: F) -> Result<()> + where + F: FnOnce(String) + Send + 'static, + { + let client = self.provider.sign_in(on_url).await?; + *self.client.write().await = Some(Arc::new(client)); + self.recovery_used.store(false, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + /// Forget the cached credentials and drop the current client. + pub async fn sign_out(&self) { + self.provider.sign_out(); + *self.client.write().await = None; + self.recovery_used.store(false, std::sync::atomic::Ordering::Relaxed); + } +} diff --git a/crates/core/src/spotify_bridge.rs b/crates/core/src/spotify_bridge.rs new file mode 100644 index 0000000..1c2f5eb --- /dev/null +++ b/crates/core/src/spotify_bridge.rs @@ -0,0 +1,764 @@ +//! Pure mapping from the Spotify provider's models ([`ryotunes_spotify`]) to the YouTube-shaped +//! browse JSON the client already renders. The daemon calls these when the selected provider is +//! Spotify (browsing) or an id carries a `spotify:` prefix (a track/album/artist/playlist page). +//! +//! Every id the daemon emits is prefixed `spotify::`; the crate returns bare base62 +//! ids, so these functions add the prefix on the way out (and the daemon strips it on the way back +//! in). Durations are rendered "m:ss" (or "h:mm:ss" past an hour), play counts abbreviated the way +//! YouTube writes them ("547M"). Cards and song rows are built as the real [`innertube`] structs and +//! serialised through them, so serde field naming stays byte-identical to the YouTube surfaces. +//! +//! These are pure: no network, no `AppState`, no async. The daemon fetches the crate models; this +//! turns them into the client's shapes. + +use std::time::Duration; + +use innertube::models::metadata::ArtistRun; +use innertube::{ + AlbumPage, ArtistCarousel, ArtistPage, BrowseItem, HomePage, PlaylistPage, Rating, + SearchResults, Section, SongItem, +}; +use ryotunes_spotify::{ + Album, AlbumDetail, Artist, ArtistRef, GenreItem, HomeFeed, Lyrics, LyricsLine, Playlist, + PlaylistDetail, ReleaseType, SavedArtist, SearchResults as SpotifyResults, Track, +}; + +use crate::lyrics::{LyricLine, LyricWord, Lyrics as DaemonLyrics}; + +const TRACK: &str = "spotify:track:"; +const ALBUM: &str = "spotify:album:"; +const ARTIST: &str = "spotify:artist:"; +const PLAYLIST: &str = "spotify:playlist:"; + +/// The stable id of the liked-songs pseudo-playlist (the daemon pages `client.liked` behind it). +pub const LIKED_PLAYLIST_ID: &str = "spotify:playlist:liked"; + +// --- tracks ---------------------------------------------------------------------------------- + +/// A Spotify [`Track`] as a queue/playlist [`SongItem`]. `is_upload`/`is_video` are always false +/// (Spotify has neither), the thumbnail is the track cover, and the rating reads "indifferent" — +/// Spotify's saved state is a library flag, not a per-row like the row UI can toggle. +pub fn track_to_song(track: &Track) -> SongItem { + SongItem { + video_id: format!("{TRACK}{}", track.id.clone().unwrap_or_default()), + title: track.name.clone(), + artists: track.artists.clone(), + artist_id: first_artist_id(&track.artist_refs), + artist_runs: artist_runs(&track.artist_refs), + album: (!track.album.is_empty()).then(|| track.album.clone()), + album_id: track.album_id.as_ref().map(|id| format!("{ALBUM}{id}")), + duration: Some(fmt_duration(track.duration)), + play_count: track.playcount.map(abbreviate), + thumbnail: track.cover.clone(), + set_video_id: None, + added_by: None, + added_by_avatar: None, + rating: Some(Rating::Indifferent), + queued_by: None, + queued: false, + queued_end: false, + queued_from: None, + autoplay: false, + is_video: false, + is_upload: false, + explicit: track.explicit, + } +} + +/// A Spotify [`Track`] as a `song` [`BrowseItem`] (a home/search card). +pub fn track_to_card(track: &Track) -> BrowseItem { + BrowseItem { + kind: "song", + id: format!("{TRACK}{}", track.id.clone().unwrap_or_default()), + title: track.name.clone(), + subtitle: (!track.artists.is_empty()).then(|| track.artists.clone()), + thumbnail: track.cover.clone(), + duration: Some(fmt_duration(track.duration)), + artist_runs: artist_runs(&track.artist_refs), + play_count: track.playcount.map(abbreviate), + is_video: false, + is_upload: false, + explicit: track.explicit, + } +} + +// --- cards ----------------------------------------------------------------------------------- + +/// A Spotify [`Album`] as an `album` [`BrowseItem`]. +pub fn album_to_card(album: &Album) -> BrowseItem { + BrowseItem { + kind: "album", + id: format!("{ALBUM}{}", album.id), + title: album.name.clone(), + subtitle: (!album.artists.is_empty()).then(|| album.artists.clone()), + thumbnail: album.cover.clone().or_else(|| album.cover_large.clone()), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A saved-library artist as an `artist` [`BrowseItem`]. +pub fn saved_artist_to_card(artist: &SavedArtist) -> BrowseItem { + BrowseItem { + kind: "artist", + id: format!("{ARTIST}{}", artist.id), + title: artist.name.clone(), + subtitle: None, + thumbnail: artist.cover.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// A search-result [`ArtistRef`] as an `artist` [`BrowseItem`]. `None` when the ref links no artist +/// id (Spotify search derives artists from track hits; an unlinked name cannot open a page). +pub fn artist_ref_to_card(artist: &ArtistRef) -> Option { + let id = artist.id.as_ref()?; + Some(BrowseItem { + kind: "artist", + id: format!("{ARTIST}{id}"), + title: artist.name.clone(), + subtitle: None, + thumbnail: None, + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + }) +} + +/// A Spotify [`Playlist`] as a `playlist` [`BrowseItem`]. +pub fn playlist_to_card(playlist: &Playlist) -> BrowseItem { + BrowseItem { + kind: "playlist", + id: format!("{PLAYLIST}{}", playlist.id), + title: playlist.name.clone(), + subtitle: (!playlist.owner.is_empty()).then(|| playlist.owner.clone()), + thumbnail: playlist.cover.clone(), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +/// The fixed liked-songs row that heads the Spotify library ([`LIKED_PLAYLIST_ID`]). +pub fn liked_card() -> BrowseItem { + BrowseItem { + kind: "playlist", + id: LIKED_PLAYLIST_ID.to_owned(), + title: "Liked Songs".to_owned(), + subtitle: None, + thumbnail: None, + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +// --- pages ----------------------------------------------------------------------------------- + +/// A Spotify [`AlbumDetail`] as the album page the client renders. `playlist_id` carries the +/// `spotify:album:` id so the library-save toggle round-trips back to `set_album_saved`. +pub fn album_page(detail: &AlbumDetail) -> AlbumPage { + let album = &detail.album; + AlbumPage { + title: Some(album.name.clone()), + artist: (!album.artists.is_empty()).then(|| album.artists.clone()), + artist_id: first_artist_id(&album.artist_refs), + artist_runs: artist_runs(&album.artist_refs), + artist_thumbnail: None, + subtitle: Some(album_type_line(album)), + second_subtitle: Some(song_count(detail.tracks.len().max(album.track_count as usize))), + description: None, + thumbnail: album.cover_large.clone().or_else(|| album.cover.clone()), + items: detail.tracks.iter().map(track_to_song).collect(), + continuation: None, + explicit: detail.tracks.iter().any(|track| track.explicit), + playlist_id: Some(format!("{ALBUM}{}", album.id)), + in_library: false, + sections: Vec::new(), + } +} + +/// A Spotify artist ([`Artist`], from `client.artist`) as the artist page: top tracks become the +/// "Popular" shelf, and the discography is grouped by release type into card carousels. +pub fn artist_page(id: &str, artist: &Artist) -> ArtistPage { + ArtistPage { + name: Some(artist.name.clone()), + thumbnail: artist.cover_large.clone(), + description: artist.biography.clone(), + subscribers: None, + monthly_listeners: artist + .monthly_listeners + .map(|n| format!("{} monthly listeners", group_thousands(n))), + channel_id: format!("{ARTIST}{id}"), + subscribed: false, + radio_playlist_id: None, + top_songs: artist.top_tracks.iter().map(track_to_song).collect(), + top_songs_id: None, + sections: artist_sections(&artist.albums), + } +} + +/// A Spotify [`PlaylistDetail`] as the playlist page. +pub fn playlist_page(detail: &PlaylistDetail) -> PlaylistPage { + let playlist = &detail.playlist; + PlaylistPage { + title: Some(playlist.name.clone()), + subtitle: (!playlist.owner.is_empty()).then(|| playlist.owner.clone()), + thumbnail: playlist.cover.clone(), + description: None, + privacy: Some(if playlist.public { "PUBLIC" } else { "PRIVATE" }.to_owned()), + cover: None, + items: detail.tracks.iter().map(track_to_song).collect(), + continuation: None, + owned: playlist.owned, + collaborative: playlist.collaborative, + sort_menu: None, + } +} + +/// The liked-songs page: the paged saved tracks under the "Liked Songs" title. +pub fn liked_page(tracks: &[Track]) -> PlaylistPage { + let items: Vec = tracks.iter().map(track_to_song).collect(); + PlaylistPage { + title: Some("Liked Songs".to_owned()), + subtitle: Some(song_count(items.len())), + thumbnail: None, + description: None, + privacy: Some("PRIVATE".to_owned()), + cover: None, + items, + continuation: None, + owned: true, + collaborative: false, + sort_menu: None, + } +} + +/// A Spotify [`HomeFeed`] as the home page: chips are always empty, then "Listen again", +/// "Quick picks", then each genre section (playlists/albums; bare genres are dropped — they have +/// no navigable card kind). Empty sections are omitted. +pub fn home_page(feed: &HomeFeed) -> HomePage { + let mut sections = Vec::new(); + if !feed.listen_again.is_empty() { + sections.push(card_section( + "Listen again", + feed.listen_again.iter().map(track_to_card).collect(), + )); + } + if let Some(quick) = feed.quick_picks.as_ref().filter(|quick| !quick.is_empty()) { + sections.push(card_section("Quick picks", quick.iter().map(track_to_card).collect())); + } + for section in &feed.sections { + let items: Vec = section.items.iter().filter_map(genre_item_to_card).collect(); + if !items.is_empty() { + sections.push(card_section(section.title.clone(), items)); + } + } + HomePage { chips: Vec::new(), sections, continuation: None } +} + +/// Spotify [`SpotifyResults`] as the `search_all` payload. Spotify has no "top result" shelf, so +/// `top` is empty; artists come from the track hits and drop any that link no id. +pub fn search_all(results: &SpotifyResults) -> SearchResults { + SearchResults { + top: Vec::new(), + songs: results.tracks.iter().map(track_to_card).collect(), + albums: results.albums.iter().map(album_to_card).collect(), + artists: results.artists.iter().filter_map(artist_ref_to_card).collect(), + playlists: results.playlists.iter().map(playlist_to_card).collect(), + continuation: None, + } +} + +/// One `search_cards` category (`songs`|`albums`|`artists`|`playlists`) as a flat card list. +pub fn search_cards(results: &SpotifyResults, category: &str) -> Vec { + match category { + "songs" => results.tracks.iter().map(track_to_card).collect(), + "albums" => results.albums.iter().map(album_to_card).collect(), + "artists" => results.artists.iter().filter_map(artist_ref_to_card).collect(), + "playlists" => results.playlists.iter().map(playlist_to_card).collect(), + _ => Vec::new(), + } +} + +/// The `search_page` items: the track hits as song rows. +pub fn search_songs(results: &SpotifyResults) -> Vec { + results.tracks.iter().map(track_to_song).collect() +} + +// --- lyrics ---------------------------------------------------------------------------------- + +/// Spotify [`Lyrics`] as the daemon's lyrics shape. Synced lines carry per-line and (when present) +/// per-word timings; plain lyrics become un-timed lines. +pub fn lyrics_to_lyrics(lyrics: &Lyrics) -> DaemonLyrics { + match lyrics { + Lyrics::Synced { lines } => DaemonLyrics { + source: "Spotify".to_owned(), + synced: true, + instrumental: false, + lines: lines.iter().map(synced_line).collect(), + }, + Lyrics::Plain { text, .. } => DaemonLyrics { + source: "Spotify".to_owned(), + synced: false, + instrumental: false, + lines: text.lines().map(|line| LyricLine::simple(None, line.to_owned())).collect(), + }, + } +} + +fn synced_line(line: &LyricsLine) -> LyricLine { + LyricLine { + time_ms: Some(millis(line.start)), + end_time_ms: line.end.map(millis), + text: line.text.clone(), + words: line.words.as_ref().map(|words| { + words + .iter() + .map(|word| LyricWord { + text: word.text.clone(), + start_ms: millis(word.start), + end_ms: millis(word.end), + }) + .collect() + }), + translation: None, + } +} + +// --- helpers --------------------------------------------------------------------------------- + +fn card_section(title: impl Into, items: Vec) -> Section { + Section { + title: title.into(), + title_is_artist: false, + items, + more_browse_id: None, + more_params: None, + } +} + +fn genre_item_to_card(item: &GenreItem) -> Option { + match item { + GenreItem::Playlist(playlist) => Some(playlist_to_card(playlist)), + GenreItem::Album(album) => Some(album_to_card(album)), + GenreItem::Genre(_) => None, + } +} + +fn artist_sections(albums: &[Album]) -> Vec { + let mut order: Vec = Vec::new(); + for album in albums { + if !order.contains(&album.release_type) { + order.push(album.release_type); + } + } + order + .into_iter() + .map(|kind| ArtistCarousel { + title: format!("{}s", kind.label()), + items: albums + .iter() + .filter(|album| album.release_type == kind) + .map(album_to_card) + .collect(), + more_browse_id: None, + more_params: None, + }) + .collect() +} + +fn artist_runs(refs: &[ArtistRef]) -> Vec { + let mut runs = Vec::with_capacity(refs.len().saturating_mul(2)); + for (i, artist) in refs.iter().enumerate() { + if i > 0 { + runs.push(ArtistRun { text: ", ".to_owned(), id: None }); + } + runs.push(ArtistRun { + text: artist.name.clone(), + id: artist.id.as_ref().map(|id| format!("{ARTIST}{id}")), + }); + } + runs +} + +fn first_artist_id(refs: &[ArtistRef]) -> Option { + refs.iter().find_map(|artist| artist.id.as_ref().map(|id| format!("{ARTIST}{id}"))) +} + +fn album_type_line(album: &Album) -> String { + if album.year > 0 { + format!("{} • {}", album.release_type.label(), album.year) + } else { + album.release_type.label().to_owned() + } +} + +fn song_count(count: usize) -> String { + format!("{count} {}", if count == 1 { "song" } else { "songs" }) +} + +fn millis(duration: Duration) -> u64 { + duration.as_millis() as u64 +} + +/// "m:ss", or "h:mm:ss" once the track runs an hour or more. +fn fmt_duration(duration: Duration) -> String { + let total = duration.as_secs(); + let (hours, minutes, seconds) = (total / 3600, (total % 3600) / 60, total % 60); + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes}:{seconds:02}") + } +} + +/// A play count the way YouTube abbreviates it: "547M", "2.5B", "1.2K", "999". +fn abbreviate(n: u64) -> String { + fn scale(value: f64, unit: &str) -> String { + if value >= 10.0 { + format!("{}{unit}", value.round() as u64) + } else { + let text = format!("{value:.1}"); + format!("{}{unit}", text.strip_suffix(".0").unwrap_or(&text)) + } + } + if n >= 1_000_000_000 { + scale(n as f64 / 1e9, "B") + } else if n >= 1_000_000 { + scale(n as f64 / 1e6, "M") + } else if n >= 1_000 { + scale(n as f64 / 1e3, "K") + } else { + n.to_string() + } +} + +/// A full count with thousands separators ("12,345,678"), for the artist listener line. +fn group_thousands(n: u64) -> String { + let digits = n.to_string(); + let len = digits.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (len - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use ryotunes_spotify::{GenreSection, LyricsWord}; + use std::sync::Arc; + + fn track() -> Track { + Track { + id: Some("6rqhFgbbKwnb9MLmUQDhG6".to_owned()), + name: "So Long, London".to_owned(), + playable: true, + artists: "Taylor Swift, Bon Iver".to_owned(), + artist_refs: vec![ + ArtistRef { + name: "Taylor Swift".to_owned(), + id: Some("06HL4z0CvFAxyc27GXpf02".to_owned()), + }, + ArtistRef { name: "Bon Iver".to_owned(), id: None }, + ], + album: "The Tortured Poets Department".to_owned(), + album_id: Some("1Mo4aZ8ho7Gm2ao5VOZjzna".to_owned()), + cover: Some("https://i.scdn.co/image/cover".to_owned()), + duration: Duration::from_secs(4 * 60 + 22), + added_at: None, + added_by: None, + playcount: Some(547_000_000), + popularity: 88, + explicit: true, + track_number: 5, + disc_number: 1, + tags: Vec::new(), + languages: Vec::new(), + credits: Vec::new(), + } + } + + fn album() -> Album { + Album { + id: "1Mo4aZ8ho7Gm2ao5VOZjzna".to_owned(), + name: "The Tortured Poets Department".to_owned(), + artists: "Taylor Swift".to_owned(), + artist_refs: vec![ArtistRef { + name: "Taylor Swift".to_owned(), + id: Some("06HL4z0CvFAxyc27GXpf02".to_owned()), + }], + cover: Some("https://i.scdn.co/image/album".to_owned()), + cover_large: Some("https://i.scdn.co/image/album-large".to_owned()), + release_type: ReleaseType::Album, + year: 2024, + track_count: 16, + release_date: "2024-04-19".to_owned(), + label: "Republic".to_owned(), + copyrights: Vec::new(), + added_at: None, + } + } + + fn single() -> Album { + Album { id: "single1".to_owned(), release_type: ReleaseType::Single, ..album() } + } + + fn playlist() -> Playlist { + Playlist { + id: "37i9dQZF1DXcBWIGoYBM5M".to_owned(), + name: "Today's Top Hits".to_owned(), + owner: "Spotify".to_owned(), + owner_id: "spotify".to_owned(), + owned: false, + collaborative: false, + blend: false, + public: true, + cover: Some("https://i.scdn.co/image/playlist".to_owned()), + track_count: 50, + modified_at: None, + } + } + + #[test] + fn track_to_song_prefixes_ids_and_formats() { + let song = track_to_song(&track()); + assert_eq!(song.video_id, "spotify:track:6rqhFgbbKwnb9MLmUQDhG6"); + assert_eq!(song.title, "So Long, London"); + assert_eq!(song.artists, "Taylor Swift, Bon Iver"); + assert_eq!(song.artist_id.as_deref(), Some("spotify:artist:06HL4z0CvFAxyc27GXpf02")); + assert_eq!(song.album.as_deref(), Some("The Tortured Poets Department")); + assert_eq!(song.album_id.as_deref(), Some("spotify:album:1Mo4aZ8ho7Gm2ao5VOZjzna")); + assert_eq!(song.duration.as_deref(), Some("4:22")); + assert_eq!(song.play_count.as_deref(), Some("547M")); + assert!(song.explicit); + assert!(!song.is_video && !song.is_upload); + assert_eq!(song.rating, Some(Rating::Indifferent)); + // Runs reproduce the display line, each linked name carrying a spotify: artist id. + assert_eq!(song.artist_runs.len(), 3); + assert_eq!(song.artist_runs[0].text, "Taylor Swift"); + assert_eq!( + song.artist_runs[0].id.as_deref(), + Some("spotify:artist:06HL4z0CvFAxyc27GXpf02") + ); + assert_eq!(song.artist_runs[1].text, ", "); + assert_eq!(song.artist_runs[1].id, None); + assert_eq!(song.artist_runs[2].text, "Bon Iver"); + assert_eq!(song.artist_runs[2].id, None); + } + + #[test] + fn song_item_serialises_snake_case() { + let json = serde_json::to_value(track_to_song(&track())).unwrap(); + assert_eq!(json["video_id"], "spotify:track:6rqhFgbbKwnb9MLmUQDhG6"); + assert_eq!(json["artist_runs"][0]["text"], "Taylor Swift"); + assert_eq!(json["duration"], "4:22"); + assert_eq!(json["play_count"], "547M"); + assert_eq!(json["rating"], "indifferent"); + assert_eq!(json["is_video"], false); + } + + #[test] + fn track_to_card_is_a_song_card() { + let json = serde_json::to_value(track_to_card(&track())).unwrap(); + assert_eq!(json["kind"], "song"); + assert_eq!(json["id"], "spotify:track:6rqhFgbbKwnb9MLmUQDhG6"); + assert_eq!(json["subtitle"], "Taylor Swift, Bon Iver"); + assert_eq!(json["duration"], "4:22"); + assert_eq!(json["playCount"], "547M"); + assert_eq!(json["artistRuns"][0]["id"], "spotify:artist:06HL4z0CvFAxyc27GXpf02"); + } + + #[test] + fn album_and_playlist_cards() { + let a = serde_json::to_value(album_to_card(&album())).unwrap(); + assert_eq!(a["kind"], "album"); + assert_eq!(a["id"], "spotify:album:1Mo4aZ8ho7Gm2ao5VOZjzna"); + assert_eq!(a["subtitle"], "Taylor Swift"); + assert_eq!(a["thumbnail"], "https://i.scdn.co/image/album"); + + let p = serde_json::to_value(playlist_to_card(&playlist())).unwrap(); + assert_eq!(p["kind"], "playlist"); + assert_eq!(p["id"], "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"); + assert_eq!(p["subtitle"], "Spotify"); + } + + #[test] + fn liked_card_is_fixed() { + let json = serde_json::to_value(liked_card()).unwrap(); + assert_eq!(json["kind"], "playlist"); + assert_eq!(json["id"], "spotify:playlist:liked"); + assert_eq!(json["title"], "Liked Songs"); + } + + #[test] + fn album_page_groups_and_saves() { + let detail = AlbumDetail { album: album(), tracks: vec![track()] }; + let page = album_page(&detail); + assert_eq!(page.title.as_deref(), Some("The Tortured Poets Department")); + assert_eq!(page.subtitle.as_deref(), Some("Album • 2024")); + assert_eq!(page.second_subtitle.as_deref(), Some("16 songs")); + assert_eq!(page.artist_id.as_deref(), Some("spotify:artist:06HL4z0CvFAxyc27GXpf02")); + assert_eq!(page.playlist_id.as_deref(), Some("spotify:album:1Mo4aZ8ho7Gm2ao5VOZjzna")); + assert!(page.explicit); + assert_eq!(page.items.len(), 1); + } + + #[test] + fn artist_page_popular_and_release_sections() { + let artist = Artist { + name: "Taylor Swift".to_owned(), + cover_large: Some("https://i.scdn.co/image/artist".to_owned()), + biography: Some("An American singer-songwriter.".to_owned()), + monthly_listeners: Some(84_231_004), + top_tracks: vec![track()], + albums: vec![album(), single()], + }; + let page = artist_page("06HL4z0CvFAxyc27GXpf02", &artist); + assert_eq!(page.channel_id, "spotify:artist:06HL4z0CvFAxyc27GXpf02"); + assert_eq!(page.monthly_listeners.as_deref(), Some("84,231,004 monthly listeners")); + assert_eq!(page.top_songs.len(), 1); + assert_eq!(page.sections.len(), 2); + assert_eq!(page.sections[0].title, "Albums"); + assert_eq!(page.sections[1].title, "Singles"); + assert_eq!(page.sections[0].items[0].id, "spotify:album:1Mo4aZ8ho7Gm2ao5VOZjzna"); + } + + #[test] + fn playlist_and_liked_pages() { + let detail = PlaylistDetail { playlist: playlist(), tracks: vec![track(), track()] }; + let page = playlist_page(&detail); + assert_eq!(page.title.as_deref(), Some("Today's Top Hits")); + assert_eq!(page.subtitle.as_deref(), Some("Spotify")); + assert_eq!(page.privacy.as_deref(), Some("PUBLIC")); + assert!(!page.owned); + assert_eq!(page.items.len(), 2); + + let liked = liked_page(&[track()]); + assert_eq!(liked.title.as_deref(), Some("Liked Songs")); + assert_eq!(liked.subtitle.as_deref(), Some("1 song")); + assert!(liked.owned); + } + + #[test] + fn home_page_orders_sections_and_drops_empties() { + let feed = HomeFeed { + listen_again: vec![track()], + quick_picks: Some(vec![track(), track()]), + sections: vec![ + GenreSection { + title: "Made for you".to_owned(), + items: vec![GenreItem::Playlist(playlist()), GenreItem::Album(album())], + }, + GenreSection { title: "Empty".to_owned(), items: Vec::new() }, + ], + }; + let page = home_page(&feed); + assert!(page.chips.is_empty()); + assert_eq!(page.sections.len(), 3); + assert_eq!(page.sections[0].title, "Listen again"); + assert_eq!(page.sections[0].items.len(), 1); + assert_eq!(page.sections[1].title, "Quick picks"); + assert_eq!(page.sections[1].items.len(), 2); + assert_eq!(page.sections[2].title, "Made for you"); + assert_eq!(page.sections[2].items.len(), 2); + assert!(page.continuation.is_none()); + } + + #[test] + fn search_all_and_cards() { + let results = SpotifyResults { + tracks: vec![track()], + albums: vec![album()], + artists: vec![ + ArtistRef { + name: "Taylor Swift".to_owned(), + id: Some("06HL4z0CvFAxyc27GXpf02".to_owned()), + }, + ArtistRef { name: "Unlinked".to_owned(), id: None }, + ], + playlists: vec![playlist()], + }; + let all = search_all(&results); + assert!(all.top.is_empty()); + assert_eq!(all.songs.len(), 1); + assert_eq!(all.albums.len(), 1); + assert_eq!(all.artists.len(), 1); // the id-less ref is dropped + assert_eq!(all.playlists.len(), 1); + + assert_eq!(search_cards(&results, "albums").len(), 1); + assert_eq!(search_cards(&results, "artists")[0].kind, "artist"); + assert!(search_cards(&results, "unknown").is_empty()); + assert_eq!(search_songs(&results).len(), 1); + } + + #[test] + fn duration_and_abbreviation_edges() { + assert_eq!(fmt_duration(Duration::from_secs(9)), "0:09"); + assert_eq!(fmt_duration(Duration::from_secs(62)), "1:02"); + assert_eq!(fmt_duration(Duration::from_secs(3 * 3600 + 4 * 60 + 5)), "3:04:05"); + assert_eq!(abbreviate(999), "999"); + assert_eq!(abbreviate(1_200), "1.2K"); + assert_eq!(abbreviate(547_000_000), "547M"); + assert_eq!(abbreviate(2_500_000_000), "2.5B"); + assert_eq!(abbreviate(1_000_000), "1M"); + assert_eq!(group_thousands(84_231_004), "84,231,004"); + } + + #[test] + fn lyrics_synced_words_map_through() { + let lyrics = Lyrics::Synced { + lines: Arc::from(vec![LyricsLine { + start: Duration::from_millis(1500), + end: Some(Duration::from_millis(4200)), + text: "So long, London".to_owned(), + romanized: None, + words: Some(vec![LyricsWord { + start: Duration::from_millis(1500), + end: Duration::from_millis(2000), + text: "So".to_owned(), + }]), + secondary: Vec::new(), + voice: Default::default(), + }]), + }; + let mapped = lyrics_to_lyrics(&lyrics); + assert!(mapped.synced); + assert_eq!(mapped.source, "Spotify"); + assert_eq!(mapped.lines.len(), 1); + assert_eq!(mapped.lines[0].time_ms, Some(1500)); + assert_eq!(mapped.lines[0].end_time_ms, Some(4200)); + let words = mapped.lines[0].words.as_ref().unwrap(); + assert_eq!(words[0].text, "So"); + assert_eq!(words[0].start_ms, 1500); + assert_eq!(words[0].end_ms, 2000); + + let plain = lyrics_to_lyrics(&Lyrics::plain("line one\nline two")); + assert!(!plain.synced); + assert_eq!(plain.lines.len(), 2); + assert_eq!(plain.lines[1].text, "line two"); + } +} diff --git a/src-tauri/src/state.rs b/crates/core/src/state.rs similarity index 78% rename from src-tauri/src/state.rs rename to crates/core/src/state.rs index f921a47..fd3a607 100644 --- a/src-tauri/src/state.rs +++ b/crates/core/src/state.rs @@ -5,18 +5,23 @@ use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::time::Duration; + +use ryotunes_spotify::{StreamControls, StreamEvent, StreamHandle}; use innertube::{ AccountIdentity, AccountInfo, AudioQuality, Clients, InnerTube, SongItem, MAIN_CLIENT, }; use listen_protocol::{Playback, PlaybackKind, Track}; -use player::Player; -use tauri::{AppHandle, Emitter}; +use player::{AudioFx, Player}; use tokio::sync::Mutex; use crate::db::{now_secs, Db}; use crate::discord::DiscordHandle; +use crate::host::{EventSink, LoginFlow, Paths}; use crate::listentogether::{LtSession, SyncCommand}; use crate::media::MediaHandle; use crate::orchestrator::{Orchestrator, PlaybackData, PlaybackPing, ResolveError}; @@ -37,6 +42,49 @@ pub const RECENTLY_PLAYED_WINDOW_SECS: i64 = 7 * 24 * 60 * 60; pub const REDISCOVER_OLDER_THAN_SECS: i64 = 14 * 24 * 60 * 60; pub const SMART_PLAYLIST_LIMIT: usize = 40; pub const LOCAL_PLAYLIST_PREFIX: &str = "RYOTUNES_LOCAL_PLAYLIST:"; +/// The device's own Liked Songs: where the heart lands whenever YouTube's rating is not on the +/// table (no Google account, a SoundCloud or local track). A plain local playlist with a fixed +/// id, created on the first like, so it pages, plays and edits like any other. +pub const LIKED_SONGS_ID: &str = "RYOTUNES_LOCAL_PLAYLIST:liked"; +pub const LIKED_SONGS_TITLE: &str = "Liked Songs"; + +/// The persisted SoundCloud OAuth blob (access + rotating refresh token + session cookie). +/// Deliberately NOT in [`UI_SETTINGS`]: like `session_cookie`, it is auth material the +/// renderer must never see; only the daemon's sign-in/rotate paths write it. +pub const SOUNDCLOUD_AUTH_KEY: &str = "soundcloud_auth"; + +/// Settings the UI is allowed to read *and write*. Session/auth material (`session_cookie`, +/// `selected_identity_json`, `data_sync_id`, `account_json`, `account_selection_pending`, +/// `visitor_data`) and internal blobs (`queue_json`, `queue_index`, `queue_position`) never cross +/// into the renderer: they'd otherwise ship the login credential out on every open, and the +/// renderer can't overwrite them either. Lives here so `settings_snapshot`, the Tauri host and the +/// daemon share one list. +pub const UI_SETTINGS: [&str; 14] = [ + "volume", + "proxy", + "quality", + "enable_history", + "disabled_stream_clients", + "discord_rpc", + "discord_presence_name", + "close_to_tray", + "autostart", + "autoplay", + "prevent_duplicates", + "lyrics_boidu", + "ui_scale", + "low_resource_mode", +]; + +/// Where the personal store (Home Shortcuts, sidebar pins, play recency, Home arrangement) lives: +/// one JSON blob under this settings key. It moved off the Svelte app's browser `localStorage`, +/// which a native client can't read, so it belongs to the daemon like any other user state. Not in +/// [`UI_SETTINGS`] on purpose: it is written through the dedicated `set_personal` method (which +/// validates and emits `personal-changed`), never the generic `set_setting` allowlist. +pub const PERSONAL_JSON_KEY: &str = "personal_json"; +/// A ceiling so a corrupt or hostile client can't wedge the store with an unbounded blob. Real +/// stores are a few KiB; a MiB is orders of magnitude of headroom and still bounds the write. +const MAX_PERSONAL_BYTES: usize = 1024 * 1024; pub fn is_local_playlist_id(id: &str) -> bool { id.starts_with(LOCAL_PLAYLIST_PREFIX) @@ -51,12 +99,12 @@ pub struct AppState { pub clients: Clients, pub player: Player, pub db: Arc, - pub app: AppHandle, + pub sink: Arc, + pub login: Arc, + pub paths: Paths, pub orchestrator: Arc, /// Listen Together session (session protocol). Drives host broadcasts + guest gating. pub lt: Arc, - /// mpv's on-disk audio cache directory, wiped by the Settings → Clear caches action. - cache_dir: std::path::PathBuf, /// OS media integration (MPRIS/SMTC/NowPlaying). `None` if it failed to init. fail-soft policy. media: Option, /// Discord rich presence. Fed the same track/playback changes as `media`; gated on the @@ -64,6 +112,8 @@ pub struct AppState { discord: Option, /// Last.fm scrobbler. Same feed again; parks until a session key is set (titlebar button). pub lastfm: crate::lastfm::LastfmHandle, + /// The Spotify provider: sign-in, the client, and the catalogue selector. + pub spotify: Arc, queue: Mutex, /// Bumped on every explicit `play`/jump so superseded async resolves discard their result /// (cancellation without JoinHandle bookkeeping). stream selection §6. @@ -99,6 +149,48 @@ pub struct AppState { /// Runtime mirror of Settings → Low resource mode. The renderer owns most of the policy; this /// bit lets the native event pump and startup helpers lower their cadence too. low_resource_mode: AtomicBool, + /// The Sound dialog's live audio effects (spec section 7), mirrored to every client through + /// the `audio-fx` event and the subscribe snapshot. Applied to the player as one `af` chain; + /// kept here so a newly opened window can be told the current values. + audio_fx: parking_lot::RwLock, + /// The one live Spotify stream, if a `spotify:track:` id is playing: its transport controls + /// (seek/play) plus the task watching librespot's events. A fresh `resolve` of a Spotify id + /// replaces it (dropping the previous handle and aborting its watcher); a non-Spotify track or + /// an explicit stop drops it. `None` whenever YouTube/local/radio audio is playing. + spotify_stream: parking_lot::Mutex>, + /// Per-track position offset (secs, f64 bits) for the playing Spotify track: mpv restarts its + /// `time-pos` at 0 every time the FIFO is (re)loaded, so a seek stores the target here and + /// position ticks report `base + mpv time-pos`. Reset to 0 on every track start; always 0 + /// while a non-Spotify track plays, so adding it is a no-op there. + spotify_seek_base: AtomicU64, + /// The SoundCloud provider (guest api-v2 client), cache dir `/soundcloud`. SoundCloud + /// tracks stream over plain HTTPS, so unlike `spotify_stream` there is no live handle to hold. + pub soundcloud: Arc, + /// Play/like/genre for SoundCloud tracks seen by `resolve`, keyed by `sc:track:` id, so the + /// now-playing snapshot can render the meta line without another round-trip. Bounded in + /// `remember_sc_meta`. + sc_meta: parking_lot::Mutex>, + /// Unix secs of the last on-demand visitorData (re)bootstrap attempt, so a failing session + /// retries on a cooldown instead of hammering sw.js_data on every skipped track. + last_visitor_try: AtomicU64, +} + +/// One live Spotify stream held on [`AppState`]: the cheap transport handle the daemon seeks +/// with, plus the task watching librespot's event stream for the cases mpv can't see (a track +/// that never becomes available). Aborting the watcher and dropping the controls tears it down. +struct SpotifyStream { + controls: StreamControls, + watcher: tokio::task::JoinHandle<()>, +} + +/// Play/like/genre for one SoundCloud track, remembered by [`AppState::resolve`] so the +/// now-playing snapshot can render the "plays · likes · genre" meta line. Counts are pre-formatted +/// display strings ("1.2M"); absent fields stay `None`. +#[derive(Clone, Default)] +struct ScTrackMeta { + plays: Option, + likes: Option, + genre: Option, } /// Repeat mode for the queue. Serialized lowercase for the UI + `queue_json`. @@ -236,7 +328,7 @@ fn auth_selection_pending(db: &Db) -> bool { } /// Startup compatibility: prefer the canonical model, then fall back to the pre-switcher setting. -pub(crate) fn persisted_data_sync_id(db: &Db) -> Option { +pub fn persisted_data_sync_id(db: &Db) -> Option { selected_identity_from_db(db) .and_then(|identity| identity.data_sync_id) .or_else(|| db.get_setting("data_sync_id").filter(|id| !id.is_empty())) @@ -325,27 +417,61 @@ impl AppState { clients: Clients, player: Player, db: Arc, - app: AppHandle, + sink: Arc, + login: Arc, + paths: Paths, orchestrator: Arc, lt: Arc, - cache_dir: std::path::PathBuf, media: Option, discord: Option, lastfm: crate::lastfm::LastfmHandle, ) -> Self { let low_resource_mode = db.get_setting("low_resource_mode").as_deref() == Some("true"); + let selected = db + .get_setting("provider") + .as_deref() + .and_then(crate::spotify::Provider::parse) + .unwrap_or(crate::spotify::Provider::Youtube); + let spotify = + Arc::new(crate::spotify::SpotifyState::new(paths.data_dir.join("spotify"), selected)); + let soundcloud = + Arc::new(ryotunes_soundcloud::SoundCloud::new(paths.data_dir.join("soundcloud"))); + // A captured SoundCloud sign-in survives restarts like the YouTube cookie does: the + // JSON blob lives under a settings key outside UI_SETTINGS, so the renderer can + // neither read nor overwrite it. Every token rotation writes the new pair back + // through this hook (SoundCloud's refresh token is single-use — losing a rotation + // dead-ends the session), and the restore is validated against /me by the host. + if let Some(raw) = db.get_setting(SOUNDCLOUD_AUTH_KEY) { + if let Ok(auth) = serde_json::from_str::(&raw) { + soundcloud.set_auth(Some(auth)); + } + } + let rotate_db = Arc::clone(&db); + soundcloud.set_on_rotate(std::sync::Arc::new(move |auth| { + match serde_json::to_string(&auth) { + Ok(json) => rotate_db.set_setting(SOUNDCLOUD_AUTH_KEY, &json), + Err(e) => { + tracing::error!(error = %e, "soundcloud: cannot serialize rotated auth") + } + } + })); AppState { it, clients, player, db, - app, + sink, + login, + paths, orchestrator, lt, - cache_dir, media, discord, lastfm, + spotify, + soundcloud, + sc_meta: parking_lot::Mutex::new(std::collections::HashMap::new()), + last_visitor_try: AtomicU64::new(0), queue: Mutex::new(QueueState::default()), is_playing: AtomicBool::new(false), generation: AtomicU64::new(0), @@ -358,6 +484,18 @@ impl AppState { last_persisted_fingerprint: AtomicU64::new(0), stop_after_current: AtomicBool::new(false), low_resource_mode: AtomicBool::new(low_resource_mode), + audio_fx: parking_lot::RwLock::new(AudioFx::default()), + spotify_stream: parking_lot::Mutex::new(None), + spotify_seek_base: AtomicU64::new(0), + } + } + + /// Serialize `payload` and fan it out through the host's event sink. Replaces the old + /// per-window emit; a payload that will not serialize is logged, never panicked. + pub fn emit(&self, event: &'static str, payload: T) { + match serde_json::to_value(payload) { + Ok(v) => self.sink.emit(event, v), + Err(e) => tracing::warn!(event, error = %e, "event payload not serializable"), } } @@ -387,7 +525,7 @@ impl AppState { /// discovers every server-selectable identity. A persisted matching identity wins over /// Google's current default; a new multi-channel login pauses before finalization and asks the /// UI to choose. - pub async fn sign_in(&self, cookie: String) -> Result { + pub(crate) async fn apply_cookie(&self, cookie: String) -> Result { let cookie = cookie.trim().to_owned(); if innertube::cookie_sapisid(&cookie).is_none() { return Err("Sign-in didn't complete — try signing in again.".into()); @@ -541,7 +679,7 @@ impl AppState { error.to_string() })?; self.persist_visitor_data(active_visitor_data.as_deref()); - let _ = self.app.emit("account-selection-required", ()); + self.emit("account-selection-required", ()); return Ok(SignInOutcome::SelectionRequired); } @@ -650,7 +788,7 @@ impl AppState { self.persist_visitor_data(visitor_data); self.forget_playlist_index(); self.it.set_data_sync_id(selected.data_sync_id.clone()); - let _ = self.app.emit("auth-changed", &account); + self.emit("auth-changed", &account); Ok(account) } @@ -687,7 +825,7 @@ impl AppState { self.db.delete_setting("session_cookie"); self.forget_playlist_index(); let _ = self.db.clear_auth_identity(); - let _ = self.app.emit("auth-changed", serde_json::json!({ "signedIn": false })); + self.emit("auth-changed", serde_json::json!({ "signedIn": false })); } /// Current account for the UI. New installs derive it from the canonical selected identity; @@ -713,7 +851,44 @@ impl AppState { v } - async fn resolve(&self, video_id: &str, is_upload: bool) -> Result { + /// The UI-visible settings as a JSON object: every [`UI_SETTINGS`] key present in the DB, + /// value as a string. This is what `get_settings` returns; the Tauri host layers its OS + /// autostart reconciliation on top, and the daemon serves it verbatim. Kept here so the two + /// hosts can never disagree about which settings the renderer may see. + pub fn settings_snapshot(&self) -> serde_json::Value { + let map: serde_json::Map = self + .db + .all_settings() + .into_iter() + .filter(|(k, _)| UI_SETTINGS.contains(&k.as_str())) + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect(); + serde_json::Value::Object(map) + } + + /// The stored personal blob (Home Shortcuts, pins, play recency, Home arrangement), or `{}` + /// when nothing has been saved yet. The daemon only stores it; its shape and reducers live in + /// the clients (`ui/src/lib/personal.ts`, `client/lib/personal.js`). + pub fn personal(&self) -> serde_json::Value { + read_personal(&self.db) + } + + /// Replace the personal blob. Rejects a non-object or a blob over [`MAX_PERSONAL_BYTES`] so a + /// corrupt client can't wedge the store, persists it under [`PERSONAL_JSON_KEY`], then emits + /// `personal-changed` with the blob so every other client (the mini window, a second shell) + /// follows the same store. + pub fn set_personal(&self, blob: serde_json::Value) -> Result<(), String> { + let json = validate_personal_blob(&blob)?; + self.db.set_setting(PERSONAL_JSON_KEY, &json); + self.emit("personal-changed", &blob); + Ok(()) + } + + async fn resolve( + self: &std::sync::Arc, + video_id: &str, + is_upload: bool, + ) -> Result { // A local file is its own "stream": no network, no cache, no extraction (local.rs). // The renderer sees local ids for legitimate library rows, but it is not authoritative: // require the path to have come from our native scan before handing it to mpv. @@ -726,7 +901,7 @@ impl AppState { // so — the UI drops the row (and any Shortcuts tile) on the spot instead of // leaving something that can only fail again. let removed = crate::local::forget_missing(&self.db, path); - let _ = self.app.emit("local-changed", serde_json::json!({ "removed": removed })); + self.emit("local-changed", serde_json::json!({ "removed": removed })); ResolveError::LocalMissing(path.to_owned()) }); } @@ -742,6 +917,54 @@ impl AppState { .ok_or_else(|| ResolveError::AllClientsFailed(video_id.to_owned()))?; return Ok(crate::radio::playback_data(&station)); } + // A Spotify track streams as raw PCM through a per-session FIFO, not a cacheable URL, so it + // resolves ahead of the latency cache / HEAD / orchestrator path and never touches any of + // them. Opening the stream starts librespot decoding into the FIFO immediately; mpv is + // pointed at that FIFO with the rawaudio demuxer options. + if let Some(tid) = crate::spotify::spotify_track_id(video_id) { + // `client_or_recover` gives a session that died since startup (or a restore that lost + // the race with the first client connecting) exactly one chance to come back from the + // saved credentials before this track is honestly reported as needing sign-in. + let Some(client) = self.spotify.client_or_recover().await else { + return Err(ResolveError::SignInRequired( + "Sign in to Spotify to play this".to_owned(), + )); + }; + let handle = client + .stream(tid) + .map_err(|e| ResolveError::AllClientsFailed(format!("{video_id}: {e}")))?; + let controls = handle.controls(); + let fifo = controls.fifo_path().to_string_lossy().into_owned(); + // Watch librespot's events off the mpv pump. An Unavailable track never fills the FIFO, + // so mpv would just block forever — the watcher surfaces it as an error + skip. + let watcher = { + let me = std::sync::Arc::clone(self); + let vid = video_id.to_owned(); + tokio::spawn(async move { me.watch_spotify_stream(vid, handle).await }) + }; + let previous = self.spotify_stream.lock().replace(SpotifyStream { controls, watcher }); + if let Some(prev) = previous { + prev.watcher.abort(); + } + return Ok(PlaybackData { + video_id: video_id.to_owned(), + stream_url: fifo, + itag: 0, + mpv_options: spotify_mpv_options(), + headers: Default::default(), + expires_in_seconds: 0, + loudness_db: None, + playback_ping: None, + // The queue item already carries title/artists/thumbnail/duration; skip the extra + // `client.track` round-trip so playback starts without waiting on it. + title: None, + artists: None, + duration: None, + thumbnail: None, + is_video: Some(false), + stream_client: "spotify".to_owned(), + }); + } // Latency cache first (UI state) — honor expiry, never a source of truth. // 60s safety margin: a URL that expires mid-load/mid-buffer fails as Raw(-13). let now = now_secs(); @@ -752,6 +975,7 @@ impl AppState { video_id: video_id.to_owned(), stream_url: c.url, itag: c.itag, + mpv_options: Vec::new(), headers: Default::default(), expires_in_seconds: c.expires_at - now, loudness_db: c.loudness_db, @@ -770,10 +994,80 @@ impl AppState { stream_client: "cache".to_owned(), }); } - let data = self + // A SoundCloud track streams over plain HTTPS: an HLS m3u8 mpv reads directly, no FIFO and + // no YouTube-specific headers. Its signed URL expires in ~1h, so cap the latency cache at + // 30 min and re-resolve after. Resolving fetches the track (transcodings + counts) then + // picks an HLS transcoding; a cache hit above already returned without any of this. + if let Some(id) = crate::spotify::sc_track_id(video_id) { + let track = self + .soundcloud + .track(id) + .await + .map_err(|e| ResolveError::AllClientsFailed(format!("{video_id}: {e}")))?; + let url = self + .soundcloud + .stream_url(&track) + .await + .map_err(|e| ResolveError::AllClientsFailed(format!("{video_id}: {e}")))?; + self.remember_sc_meta(video_id, &track); + const SC_CACHE_SECS: i64 = 30 * 60; + self.db.put_stream( + video_id, + &crate::db::CachedStream { + url: url.clone(), + itag: 0, + expires_at: now + SC_CACHE_SECS, + loudness_db: None, + is_video: Some(false), + ping_url: None, + ping_client: None, + }, + now, + ); + return Ok(PlaybackData { + video_id: video_id.to_owned(), + stream_url: url, + itag: 0, + mpv_options: Vec::new(), + headers: Default::default(), + expires_in_seconds: SC_CACHE_SECS, + loudness_db: None, + playback_ping: None, + title: None, + artists: None, + duration: None, + thumbnail: None, + is_video: Some(false), + stream_client: "soundcloud".to_owned(), + }); + } + let data = match self .orchestrator .resolve(video_id, is_upload, self.quality(), &self.disabled_clients()) - .await?; + .await + { + Ok(d) => d, + // Every InnerTube client answered "confirm you're not a bot": the session's + // visitorData is missing or stale (YouTube only honours the anonymous fallback + // clients with one). Fetch a fresh identity — once per cooldown window — and retry + // this resolve exactly once. Without this the one-shot startup bootstrap leaving a + // user unable to play ANY track until a restart was unrepairable. + Err(ResolveError::BotGated(vid)) => { + if self.refresh_visitor_data().await.is_none() { + return Err(ResolveError::AllClientsFailed(vid)); + } + self.orchestrator + .resolve(video_id, is_upload, self.quality(), &self.disabled_clients()) + .await + .map_err(|e| match e { + // The fresh token didn't help (or the IP itself is flagged): report the + // same generic failure users saw before, never the internal variant. + ResolveError::BotGated(v) => ResolveError::AllClientsFailed(v), + other => other, + })? + } + Err(e) => return Err(e), + }; // Never cache rustypipe URLs: googlevideo serves them only for bounded-Range requests, // which mpv doesn't send → LOADING_FAILED(-13). Caching one poisons the videoId for ~6h. if data.stream_client != "rustypipe" && !is_upload { @@ -794,10 +1088,44 @@ impl AppState { Ok(data) } + /// The on-demand visitorData (re)bootstrap a bot-gated resolve triggers: fetch a fresh + /// anonymous identity from sw.js_data, install it in the shared session, and persist it so + /// the next launch starts with one. Returns `Some(vd)` when a *new* token is in place, + /// `None` when the fetch failed or the cooldown suppressed the attempt — callers then treat + /// the gate as a plain failure rather than hammering Google. + /// + /// Cooldown lives here rather than in the orchestrator's retry because a whole broken queue + /// skips track-per-track: every skip would otherwise pay one sw.js_data round trip. + pub async fn refresh_visitor_data(self: &std::sync::Arc) -> Option { + const RETRY_COOLDOWN_SECS: u64 = 5 * 60; + let now = now_secs().max(0) as u64; + let last = self.last_visitor_try.swap(now, Ordering::Relaxed); + if last != 0 && now.saturating_sub(last) < RETRY_COOLDOWN_SECS { + tracing::debug!("visitorData refresh on cooldown; skipping"); + return None; + } + match self.it.fetch_visitor_data().await { + Ok(vd) => { + tracing::info!("visitorData re-bootstrapped after bot gate"); + self.it.set_visitor_data(Some(vd.clone())); + self.db.set_setting("visitor_data", &vd); + Some(vd) + } + Err(e) => { + tracing::warn!(error = %e, "visitorData refresh failed"); + None + } + } + } + /// Speculatively resolve one stream into the normal latency cache without touching playback. /// The UI calls this only after settled search intent / a short hover, so a subsequent Play can /// skip the /player + PoToken + HEAD waterfall and go straight to mpv. Failures are silent. - pub async fn prefetch_stream(&self, video_id: String, is_upload: bool) { + pub async fn prefetch_stream(self: &std::sync::Arc, video_id: String, is_upload: bool) { + // A Spotify id would open a real librespot stream into the shared FIFO — never on spec. + if crate::spotify::is_spotify_id(&video_id) { + return; + } let _ = self.resolve(&video_id, is_upload).await; } @@ -817,7 +1145,7 @@ impl AppState { self.db.put_radio_station(&item.video_id, &json); let uuid = station.station_uuid.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { crate::radio::count_click(&uuid).await; }); @@ -1287,8 +1615,8 @@ impl AppState { // reaches us. It is one-shot: play/next afterwards behaves normally. if self.stop_after_current.swap(false, Ordering::SeqCst) { tracing::info!("stop-after-current reached"); - let _ = self.app.emit("stop-after-current", false); - let _ = self.app.emit("playback-state", "paused"); + self.emit("stop-after-current", false); + self.emit("playback-state", "paused"); self.media_set_playing(false); self.persist_queue().await; return; @@ -1311,7 +1639,7 @@ impl AppState { // pausing here and un-pausing a second later would flicker every consumer (UI, // MPRIS, Discord). let me = self.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { let gen = me.generation.fetch_add(1, Ordering::SeqCst) + 1; if me.extend_queue_radio(gen).await > 0 { { @@ -1324,7 +1652,7 @@ impl AppState { } } else { tracing::info!("queue exhausted"); - let _ = me.app.emit("playback-state", "paused"); + me.emit("playback-state", "paused"); // mpv goes idle without flipping its pause flag, so no Paused event will fire // — tell the OS widget + Discord ourselves or they show "playing" forever // past the last song. @@ -1373,6 +1701,10 @@ impl AppState { q.history_pinged = false; q.duration = 0.0; } + // A gapless advance is only ever YouTube→YouTube (a Spotify boundary is never primed), so + // the base is already 0 — reset anyway to keep the "0 unless a Spotify track was seeked" + // invariant local and obvious. + self.set_spotify_base(0.0); if let Some(item) = self.current_item().await { self.emit_now_playing(&item, "gapless"); self.refresh_rating(&item.video_id, gen); @@ -1388,7 +1720,7 @@ impl AppState { // (its progress bar) and, worse, the *next* track-end. The generation guard inside already // makes a superseded resolve discard itself, so it's safe to detach. let me = self.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { me.prime_lookahead(gen).await; me.extend_queue_radio(gen).await; // autoplay early trigger (no-op unless tail is near) }); @@ -1485,6 +1817,15 @@ impl AppState { } } Err(e) => { + // A provider stream that needs the user signed in is not "unavailable": + // skipping past it row-by-row turns a whole Spotify queue into a storm of + // "Skipped (unavailable)" toasts and hides the one action that fixes it. + // Stop the queue on the spot and say what is actually wrong. + if let ResolveError::SignInRequired(message) = &e { + tracing::warn!(video_id = item.video_id, message, "playback needs sign-in"); + self.emit_error(&item.video_id, message); + return false; + } let mut q = self.queue.lock().await; // Deliberately ignores repeat-all: wrapping the unplayable-skip would spin // forever on a queue where nothing resolves. Skips stop at the tail. @@ -1504,9 +1845,13 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return false; // user moved on } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&item.title, &item.artists), + &data.mpv_options, + ) { self.emit_error(&item.video_id, &e.to_string()); return false; } @@ -1557,6 +1902,20 @@ impl AppState { } } } + // A fresh load always starts at position 0 (a user seek moves the base later). Clear it + // unconditionally: it is only ever non-zero after a Spotify seek, and this covers the + // Spotify→anything and anything→Spotify transitions in one place. + self.set_spotify_base(0.0); + if crate::spotify::is_spotify_id(&item.video_id) { + // mpv can't measure a FIFO's length, so seed the duration from the queue item and push + // it exactly like `on_duration` would (mpv's own `duration` events are ignored while a + // Spotify track plays). + self.seed_spotify_duration(&item).await; + } else { + // Any non-Spotify track means the previous Spotify stream (if any) is finished: drop + // its handle and stop its watcher. `resolve` already replaced it for a Spotify track. + self.drop_spotify_stream(); + } self.emit_now_playing(&item, &data.stream_client); // Search/autoplay/restored rows often carry no live likeStatus. Refresh the playing track // asynchronously without delaying audio start. @@ -1574,7 +1933,7 @@ impl AppState { // lookahead can prime into the continuation. The near-tail guard inside makes this a // no-op for almost every track start. Detached — never on the caller's path. let me = self.clone(); - tauri::async_runtime::spawn(async move { me.extend_queue_radio(gen).await }); + tokio::spawn(async move { me.extend_queue_radio(gen).await }); true } @@ -1593,6 +1952,16 @@ impl AppState { if q.lookahead_loaded == Some(next) { return; // already primed } + // A Spotify track streams through one per-client FIFO: resolving a Spotify *next* + // would start a second librespot decode into it (corrupting the current track), and + // a FIFO can't be gaplessly appended to a URL either. When either side of the + // boundary is Spotify, don't prime — `on_track_ended` loads across it explicitly. + let boundary_spotify = [q.current, next].iter().any(|&i| { + q.items.get(i).is_some_and(|it| crate::spotify::is_spotify_id(&it.video_id)) + }); + if boundary_spotify { + return; + } next }; let (next_video, next_title, next_upload) = { @@ -1664,7 +2033,9 @@ impl AppState { } // Headers are global in mpv; the direct-URL clients need none beyond UA, which the // current track already set. Just append the URL. - if let Err(e) = self.player.enqueue(&data.stream_url) { + let title = + q.items.get(next_idx).map(|i| media_title(&i.title, &i.artists)).unwrap_or_default(); + if let Err(e) = self.player.enqueue(&data.stream_url, &title) { tracing::warn!(error = %e, "enqueue lookahead failed"); return; } @@ -1690,8 +2061,8 @@ impl AppState { /// Everything the `now-playing` event carries. Shared with [`Self::playback_snapshot`] so a /// window that asks for the current track can't be told a different shape than one that /// listened for it. - fn now_playing_json(item: &SongItem, stream_client: &str) -> serde_json::Value { - serde_json::json!({ + fn now_playing_json(&self, item: &SongItem, stream_client: &str) -> serde_json::Value { + let mut now = serde_json::json!({ "videoId": item.video_id, "title": item.title, "artists": item.artists, @@ -1701,8 +2072,47 @@ impl AppState { "thumbnail": item.thumbnail, "duration": item.duration, "streamClient": stream_client, - "rating": item.rating, - }) + "rating": if item.rating.is_none() || !self.it.is_logged_in() || crate::spotify::is_sc_id(&item.video_id) { + if self.liked_locally(&item.video_id) { Some(innertube::Rating::Like) } else { item.rating } + } else { + item.rating + }, + }); + // SoundCloud tracks carry a "plays · likes · genre" meta line the player bar and Now + // Playing card render; splice in whatever `resolve` remembered for this id. + if crate::spotify::is_sc_id(&item.video_id) { + if let Some(meta) = self.sc_meta.lock().get(&item.video_id).cloned() { + let obj = now.as_object_mut().expect("json object"); + if let Some(plays) = meta.plays { + obj.insert("plays".to_owned(), serde_json::Value::String(plays)); + } + if let Some(likes) = meta.likes { + obj.insert("likes".to_owned(), serde_json::Value::String(likes)); + } + if let Some(genre) = meta.genre { + obj.insert("genre".to_owned(), serde_json::Value::String(genre)); + } + } + } + now + } + + /// Remember a SoundCloud track's play/like/genre counts (pre-formatted) so the now-playing + /// snapshot can render its meta line. Bounded: cleared past a few hundred entries so a long + /// session can't grow it without limit. + fn remember_sc_meta(&self, video_id: &str, track: &ryotunes_soundcloud::Track) { + let mut map = self.sc_meta.lock(); + if map.len() > 512 { + map.clear(); + } + map.insert( + video_id.to_owned(), + ScTrackMeta { + plays: track.plays.map(crate::soundcloud_bridge::abbreviate), + likes: track.likes.map(crate::soundcloud_bridge::abbreviate), + genre: track.genre.clone(), + }, + ); } /// What a window that opened mid-playback missed. Events are fire-and-forget, so the mini @@ -1713,10 +2123,16 @@ impl AppState { let q = self.queue.lock().await; (q.duration, q.items.get(q.current).cloned()) }; - let position = self.player.position().unwrap_or_else(|| self.current_position()); + // mpv's raw `time-pos` is relative to the last FIFO (re)load for a Spotify track; add the + // seek base (0 for everything else) so a window opening mid-playback sees the true position. + let position = self + .player + .position() + .map(|p| position_with_base(p, self.spotify_base())) + .unwrap_or_else(|| self.current_position()); self.note_position_sample(position); serde_json::json!({ - "now": item.as_ref().map(|i| Self::now_playing_json(i, "current")), + "now": item.as_ref().map(|i| self.now_playing_json(i, "current")), "paused": !self.is_playing.load(Ordering::Relaxed), "position": position, "duration": duration, @@ -1737,7 +2153,7 @@ impl AppState { } let me = self.clone(); let video_id = video_id.to_owned(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { let Some(client) = me.clients.get(innertube::METADATA_CLIENT) else { return }; let epoch = me.rate_epoch.load(Ordering::SeqCst); let rating = match me.it.next(client, Some(&video_id), None).await { @@ -1765,14 +2181,62 @@ impl AppState { if !changed { return; } - let _ = - me.app.emit("rating", serde_json::json!({ "videoId": video_id, "rating": rating })); + me.emit("rating", serde_json::json!({ "videoId": video_id, "rating": rating })); }); } + /// The queue's copy of a track (the heart rates what is playing or queued). + pub async fn queue_item(&self, video_id: &str) -> Option { + let q = self.queue.lock().await; + q.items.iter().find(|i| i.video_id == video_id).cloned() + } + + /// Whether the device's Liked Songs holds a track. + pub fn liked_locally(&self, video_id: &str) -> bool { + self.db + .local_playlist_track_json(LIKED_SONGS_ID) + .iter() + .filter_map(|j| serde_json::from_str::(j).ok()) + .any(|s| s.video_id == video_id) + } + + /// Like or unlike into the device's Liked Songs and reflect it on every queue row, so the + /// heart, the now-playing snapshot and the library agree without a network round trip. + pub async fn rate_locally(&self, video_id: &str, like: bool) -> Result<(), String> { + if like { + let item = self + .queue_item(video_id) + .await + .ok_or_else(|| "This track is not in the queue.".to_string())?; + if self.db.local_playlist(LIKED_SONGS_ID).is_none() { + self.db + .create_local_playlist(LIKED_SONGS_ID, LIKED_SONGS_TITLE) + .map_err(|e| format!("device playlist: {e}"))?; + } + let json = serde_json::to_string(&item).map_err(|e| e.to_string())?; + self.db + .add_local_playlist_track(LIKED_SONGS_ID, video_id, &json) + .map_err(|e| format!("device playlist: {e}"))?; + } else if self.db.local_playlist(LIKED_SONGS_ID).is_some() { + self.db + .remove_local_playlist_track(LIKED_SONGS_ID, video_id) + .map_err(|e| format!("device playlist: {e}"))?; + } + let rating = if like { innertube::Rating::Like } else { innertube::Rating::Indifferent }; + { + let mut q = self.queue.lock().await; + for item in q.items.iter_mut().filter(|i| i.video_id == video_id) { + item.rating = Some(rating); + } + } + self.emit("rating", serde_json::json!({ "videoId": video_id, "rating": rating })); + self.emit("library-changed", serde_json::json!({ "id": LIKED_SONGS_ID })); + Ok(()) + } + fn emit_now_playing(&self, item: &SongItem, stream_client: &str) { - let _ = self.app.emit("now-playing", Self::now_playing_json(item, stream_client)); - let _ = self.app.emit("playback-state", "playing"); + self.emit("now-playing", self.now_playing_json(item, stream_client)); + self.emit("playback-state", "playing"); // Push the same metadata to the OS media widget (fail-soft policy) and Discord. if let Some(m) = &self.media { // MPRIS/SMTC want a URL; a local track's artwork is a path, so hand it a file:// one. @@ -1808,8 +2272,6 @@ impl AppState { if let Some(m) = &self.media { m.set_playback(playing, self.current_position()); } - #[cfg(target_os = "windows")] - crate::taskbar::set_playing(&self.app, playing); if let Some(d) = &self.discord { d.set_playing(playing); } @@ -1852,6 +2314,8 @@ impl AppState { pub async fn shutdown_for_quit(&self) { self.flush_position(); let _ = self.player.stop(); + // Tear down any live Spotify stream + its watcher; nothing resumes librespot after Quit. + self.drop_spotify_stream(); self.is_playing.store(false, Ordering::Release); // The user-facing media island is the first thing Quit must remove. MediaHandle::shutdown @@ -1949,19 +2413,17 @@ impl AppState { "sourceName": &q.source_name, }) }; - let _ = self.app.emit(if unchanged { "queue-index" } else { "queue-changed" }, payload); + self.emit(if unchanged { "queue-index" } else { "queue-changed" }, payload); } fn emit_error(&self, video_id: &str, message: &str) { tracing::error!(video_id, message, "playback error"); - let _ = self - .app - .emit("playback-error", serde_json::json!({ "videoId": video_id, "message": message })); + self.emit("playback-error", serde_json::json!({ "videoId": video_id, "message": message })); } /// Guest tried a host-only playback action — explain instead of silently ignoring. fn emit_guest_hint(&self) { - let _ = self.app.emit( + self.emit( "playback-notice", serde_json::json!({ "message": "The host controls playback — click a song to add it to the session queue" }), ); @@ -1970,14 +2432,14 @@ impl AppState { /// A transient toast. Same channel as [`Self::emit_skip`], for messages that phrase themselves. fn emit_notice(&self, message: &str) { tracing::info!(message, "playback notice"); - let _ = self.app.emit("playback-notice", serde_json::json!({ "message": message })); + self.emit("playback-notice", serde_json::json!({ "message": message })); } /// A track was auto-skipped because no client could resolve it — a transient toast, not the /// persistent error banner: the queue keeps playing, so this shouldn't read as a failure. fn emit_skip(&self, title: &str) { tracing::warn!(title, "skipping unplayable track"); - let _ = self.app.emit( + self.emit( "playback-notice", serde_json::json!({ "message": format!("Skipped (unavailable): {title}") }), ); @@ -1999,7 +2461,7 @@ impl AppState { /// time-pos much faster than the UI needs; the event pump calls this for every raw sample and /// runs [`Self::on_position`] only at its throttled cadence. Pause/seek flushes therefore still /// use the exact latest sample without making every mpv tick hit SQLite/Discord/queue locks. - pub(crate) fn note_position_sample(&self, pos: f64) { + pub fn note_position_sample(&self, pos: f64) { if pos.is_finite() { self.latest_position.store(pos.to_bits(), Ordering::Relaxed); } @@ -2010,6 +2472,11 @@ impl AppState { /// watch-history ping, latched to happen exactly once per play. The ping is additionally /// gated on the `enable_history` setting + being logged in. Best-effort (errors logged). pub async fn on_position(&self, pos: f64) { + // mpv restarts `time-pos` at 0 whenever a Spotify FIFO is (re)loaded; the base carries the + // true track position across a seek. It is 0 for every non-Spotify track, so this is a + // no-op there — everything downstream (scrubber, resume, history threshold) sees the real + // position. + let pos = position_with_base(pos, self.spotify_base()); self.record_position(pos); let crossed = { let mut q = self.queue.lock().await; @@ -2038,7 +2505,10 @@ impl AppState { // Local files don't count: On Repeat is the only thing built from this table, and it's a // YouTube Music playlist — a row pointing at a path on this disk doesn't belong in it. if let Some(item) = played.filter(|i| { - !crate::local::is_local_song(&i.video_id) && !crate::radio::is_radio_id(&i.video_id) + !crate::local::is_local_song(&i.video_id) + && !crate::radio::is_radio_id(&i.video_id) + && !crate::spotify::is_spotify_id(&i.video_id) + && !crate::spotify::is_sc_id(&i.video_id) }) { if let Ok(json) = serde_json::to_string(&item) { self.db.record_play(&item.video_id, &json, now_secs(), ON_REPEAT_WINDOW_SECS); @@ -2059,7 +2529,7 @@ impl AppState { return; }; let it = self.it.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { match it.register_playback(&client, &ping.url, &cpn, None).await { Ok(()) => tracing::debug!(client = %ping.client, "watch-history ping sent"), Err(e) => tracing::warn!(error = %e, "watch-history ping failed"), @@ -2069,18 +2539,119 @@ impl AppState { /// Latest mpv-reported track duration (secs), feeding the history-ping threshold + OS scrubber. pub async fn on_duration(&self, secs: f64) { - if secs.is_finite() && secs > 0.0 { - self.queue.lock().await.duration = secs; - if let Some(m) = &self.media { - m.set_duration(secs); + if !(secs.is_finite() && secs > 0.0) { + return; + } + { + let mut q = self.queue.lock().await; + // A Spotify track's length is seeded from its queue item (`seed_spotify_duration`); + // mpv reports 0/inf for a FIFO and would clobber it. Ignore mpv here for Spotify. + let spotify = + q.items.get(q.current).is_some_and(|i| crate::spotify::is_spotify_id(&i.video_id)); + if spotify { + return; } - if let Some(d) = &self.discord { - d.set_duration(secs); + q.duration = secs; + } + if let Some(m) = &self.media { + m.set_duration(secs); + } + if let Some(d) = &self.discord { + d.set_duration(secs); + } + self.lastfm.set_duration(secs); + } + + /// The playing Spotify track's position base (secs). 0 while anything else plays. + fn spotify_base(&self) -> f64 { + f64::from_bits(self.spotify_seek_base.load(Ordering::Relaxed)) + } + + fn set_spotify_base(&self, secs: f64) { + self.spotify_seek_base.store(secs.to_bits(), Ordering::Relaxed); + } + + /// Seed a Spotify track's duration from its queue item (`m:ss`) and push it to the OS controls, + /// Discord and Last.fm exactly as [`Self::on_duration`] would — mpv can't measure the FIFO. + async fn seed_spotify_duration(&self, item: &SongItem) { + let secs = parse_duration_ms(item.duration.as_deref()) as f64 / 1000.0; + if secs <= 0.0 { + return; + } + self.queue.lock().await.duration = secs; + if let Some(m) = &self.media { + m.set_duration(secs); + } + if let Some(d) = &self.discord { + d.set_duration(secs); + } + self.lastfm.set_duration(secs); + } + + /// Drop the live Spotify stream, if any: releasing the transport controls and aborting the + /// event watcher. A no-op when nothing is streaming. Also clears the seek base. + fn drop_spotify_stream(&self) { + if let Some(prev) = self.spotify_stream.lock().take() { + prev.watcher.abort(); + } + self.set_spotify_base(0.0); + } + + /// Watch one Spotify stream's librespot events on its own task. mpv drives ordinary playback + /// (position/pause, and end-of-track via the FIFO closing → mpv EOF → `on_track_ended`), so + /// this only catches what mpv can't see: a track that never becomes available (the FIFO simply + /// never fills). Owns the [`StreamHandle`]; aborted by [`Self::drop_spotify_stream`] / a + /// replacing `resolve`. + async fn watch_spotify_stream( + self: std::sync::Arc, + video_id: String, + mut handle: StreamHandle, + ) { + while let Some(event) = handle.next_event().await { + if event == StreamEvent::Unavailable { + self.skip_unavailable_spotify(&video_id).await; + return; } - self.lastfm.set_duration(secs); } } + /// A Spotify track reported Unavailable: toast the error and skip past it, mirroring a failed + /// YouTube stream. Guarded so a stale watcher (the user already moved on) does nothing. + /// + /// Returns a boxed future on purpose: `resolve` spawns the watcher that reaches back into + /// `on_track_ended` → `start_current` → `resolve`. An explicit `dyn Future + Send` return type + /// gives that mutual recursion a concrete type and stops the `Send`-inference cycle. + fn skip_unavailable_spotify<'a>( + self: &'a Arc, + video_id: &'a str, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + let has_next = { + let q = self.queue.lock().await; + if q.items.get(q.current).map(|i| i.video_id != video_id).unwrap_or(true) { + return; // superseded — a newer track is current + } + // Strictly-forward only: never wrap here, or a repeat-all queue whose only playable + // path is this unavailable track would retry it forever. + q.current + 1 < q.items.len() + }; + self.emit_error(video_id, "This track isn't available on Spotify."); + if has_next { + // `on_track_ended` loads the next track, whose `player.load` replaces mpv's + // never-filling FIFO. + self.on_track_ended().await; + } else { + // Nothing to advance into: release mpv from the dead FIFO and settle like an + // exhausted queue. + let _ = self.player.stop(); + self.drop_spotify_stream(); + self.emit("playback-state", "paused"); + self.media_set_playing(false); + self.persist_queue().await; + } + }) + } + /// Watch-history ping enabled? Default on; only an explicit `"false"` disables it. fn history_enabled(&self) -> bool { self.db.get_setting("enable_history").map(|v| v != "false").unwrap_or(true) @@ -2103,7 +2674,11 @@ impl AppState { if !self.autoplay_enabled() || self.lt.is_guest().await { return 0; } - let (last_video, seed, existing) = { + enum AutoplaySource { + Radio { last_video: String, seed: String }, + Soundcloud { id: u64 }, + } + let (source, existing) = { let q = self.queue.lock().await; if q.repeat != RepeatMode::Off { return 0; // the queue never exhausts under repeat @@ -2112,28 +2687,46 @@ impl AppState { return 0; // tail not near yet } let Some(last) = q.items.last() else { return 0 }; - // Nothing to continue from when the queue ends on a local file: its path is not a - // videoId, and a queue of local music is exactly the case that has to work offline. - if q.radio_seed.is_none() - && (crate::local::is_local_song(&last.video_id) - || crate::radio::is_radio_id(&last.video_id)) - { - return 0; - } - let seed = q.radio_seed.clone().unwrap_or_else(|| format!("RDAMVM{}", last.video_id)); let existing: HashSet = q.items.iter().map(|i| i.video_id.clone()).collect(); - (last.video_id.clone(), seed, existing) + // A SoundCloud tail continues via the track's own related feed, not YouTube radio. + if let Some(id) = crate::spotify::sc_track_id(&last.video_id) { + (AutoplaySource::Soundcloud { id }, existing) + } else { + // Nothing to continue from when the queue ends on a local file, a live-radio + // stream, or a Spotify track: none is a YouTube videoId a radio can seed from. + if q.radio_seed.is_none() + && (crate::local::is_local_song(&last.video_id) + || crate::radio::is_radio_id(&last.video_id) + || crate::spotify::is_spotify_id(&last.video_id)) + { + return 0; + } + let seed = + q.radio_seed.clone().unwrap_or_else(|| format!("RDAMVM{}", last.video_id)); + (AutoplaySource::Radio { last_video: last.video_id.clone(), seed }, existing) + } }; - let Some(client) = self.clients.get(innertube::METADATA_CLIENT) else { return 0 }; // Snapshot → network → re-lock, same discipline as `prime_lookahead`; the generation - // check between them is what makes it safe. A track added *during* the fetch could - // theoretically duplicate — accepted (YTM's own radio repeats occasionally too). - let fresh = match self.it.next(client, Some(&last_video), Some(&seed)).await { - Ok(next) => next.items, - Err(e) => { - tracing::warn!(error = %e, "autoplay radio fetch failed"); - return 0; + // check below is what makes it safe. A track added *during* the fetch could theoretically + // duplicate — accepted (a radio repeats occasionally too). + let fresh: Vec = match &source { + AutoplaySource::Radio { last_video, seed } => { + let Some(client) = self.clients.get(innertube::METADATA_CLIENT) else { return 0 }; + match self.it.next(client, Some(last_video), Some(seed)).await { + Ok(next) => next.items, + Err(e) => { + tracing::warn!(error = %e, "autoplay radio fetch failed"); + return 0; + } + } } + AutoplaySource::Soundcloud { id } => match self.soundcloud.related(*id).await { + Ok(tracks) => tracks.iter().map(crate::soundcloud_bridge::track_to_song).collect(), + Err(e) => { + tracing::warn!(error = %e, "autoplay soundcloud related fetch failed"); + return 0; + } + }, }; if self.generation.load(Ordering::SeqCst) != gen { return 0; // user moved on while we fetched @@ -2143,7 +2736,7 @@ impl AppState { merge_radio(&mut q.items, fresh, existing, AUTOPLAY_BATCH) }; if added > 0 { - tracing::info!(added, seed = %seed, "autoplay extended the queue"); + tracing::info!(added, "autoplay extended the queue"); self.emit_queue().await; self.persist_queue().await; self.lt_broadcast_queue().await; @@ -2288,7 +2881,7 @@ impl AppState { self.media_set_playing(false); self.emit_now_playing(&item, "restored"); self.refresh_rating(&item.video_id, self.generation.load(Ordering::SeqCst)); - let _ = self.app.emit("playback-state", "paused"); + self.emit("playback-state", "paused"); } self.emit_queue().await; } @@ -2328,18 +2921,51 @@ impl AppState { self.db.set_setting("queue_position", &pos.to_string()); } - /// Clear both cache tiers (settings "Clear caches"): the SQLite URL cache + mpv's on-disk - /// audio bytes. File cleanup is best-effort; the current track may need to re-buffer. - pub fn clear_caches(&self) { + /// Force-clear everything playback remembers about how to get a stream: the SQLite URL + + /// lyrics cache, mpv's on-disk audio bytes, the stored PoToken, and the per-video WEB_REMIX + /// blacklist. A stale cached stream URL or a stale/missing `visitorData` both surface as mpv + /// 403s ("YouTube rejected the stream link") that no retry can fix on their own — this is + /// the manual lever for it. + /// + /// `rotate_identity` additionally drops the anonymous YouTube playback identity and + /// re-bootstraps a fresh one (the settings button; quality changes pass false). Returns + /// whether a fresh visitorData was installed — false when not rotating or when the fetch + /// failed (offline). A failed fetch still leaves the token deleted, so the next launch or + /// bot-gated resolve re-bootstraps it anyway. + pub async fn clear_caches(&self, rotate_identity: bool) -> bool { self.db.clear_stream_cache(); // The stored PoToken is a cache too, and "clear caches" is where someone goes when // playback has started behaving oddly. Dropping it costs one BotGuard bootstrap. self.db.delete_setting("potoken_session"); - if let Ok(entries) = std::fs::read_dir(&self.cache_dir) { + if let Ok(entries) = std::fs::read_dir(&self.paths.cache_dir) { for e in entries.flatten() { let _ = std::fs::remove_file(e.path()); } } + self.orchestrator.clear_web_remix_failures().await; + // The blacklist is an in-memory hint derived from cached URLs; with the cache wiped, + // WEB_REMIX gets another chance. + if !rotate_identity { + return false; + } + // Drop the persisted anonymous identity before fetching, so a fetch failure still leaves + // the daemon in the "re-bootstrap" state instead of holding the stale token it just + // complained about. Clearing the cooldown lets the on-demand bot-gate heal fire again. + self.db.delete_setting("visitor_data"); + self.it.set_visitor_data(None); + self.last_visitor_try.store(0, Ordering::Relaxed); + match self.it.fetch_visitor_data().await { + Ok(vd) => { + self.it.set_visitor_data(Some(vd.clone())); + self.db.set_setting("visitor_data", &vd); + tracing::info!("playback caches force-cleared; visitorData re-bootstrapped"); + true + } + Err(e) => { + tracing::warn!(error = %e, "visitorData refresh during cache clear failed"); + false + } + } } // --- Listen Together (session protocol) -------------------------------------------------------- @@ -2368,7 +2994,7 @@ impl AppState { /// Guest: apply a full room-state snapshot (join / reconnect / re-sync). If the current track is /// already loaded, just correct the position + play state (no reload blip); otherwise load it. - async fn lt_apply_state(&self, state: listen_protocol::RoomState) { + async fn lt_apply_state(self: &std::sync::Arc, state: listen_protocol::RoomState) { let Some(track) = state.current_track else { return }; let already_loaded = { let q = self.queue.lock().await; @@ -2400,7 +3026,7 @@ impl AppState { /// Guest: load a host-chosen track, seek to its live position, set play/pause, mirror the queue. async fn lt_apply_change_track( - &self, + self: &std::sync::Arc, track: Track, position_ms: i64, playing: bool, @@ -2434,9 +3060,13 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return; // superseded by a newer sync } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&track.title, &track.artist), + &data.mpv_options, + ) { self.emit_error(&track.id, &e.to_string()); return; } @@ -2453,7 +3083,7 @@ impl AppState { self.emit_now_playing(&item, "listen-together"); } if !playing { - let _ = self.app.emit("playback-state", "paused"); + self.emit("playback-state", "paused"); } self.emit_queue().await; // The elapsed-compensation above still can't see mpv's own decode/buffer startup. Fire one @@ -2461,7 +3091,7 @@ impl AppState { // flowing. Re-sync is seek-only for the loaded track, so there's no reload blip. if playing { let lt = self.lt.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(1200)).await; lt.request_sync().await; }); @@ -2543,7 +3173,38 @@ impl AppState { if self.lt.is_guest().await { return Ok(()); // guests can't scrub — the host controls the timeline } - self.player.seek(position).map_err(|e| e.to_string())?; + let position = position.max(0.0); + // A Spotify track: mpv can't seek a FIFO, so tell librespot to seek, record the base, and + // reload mpv on the same FIFO so it reads the post-seek PCM from 0 (base + mpv time-pos = + // the true position). Anything else is a plain mpv seek. + let spotify_title = { + let q = self.queue.lock().await; + q.items + .get(q.current) + .filter(|i| crate::spotify::is_spotify_id(&i.video_id)) + .map(|i| media_title(&i.title, &i.artists)) + }; + if let Some(title) = spotify_title { + let controls = self.spotify_stream.lock().as_ref().map(|s| s.controls.clone()); + let Some(controls) = controls else { + return Ok(()); // no live stream to seek (shouldn't happen while one plays) + }; + controls.seek(Duration::from_secs_f64(position)); + self.set_spotify_base(position); + self.player + .load( + controls.fifo_path().to_string_lossy().as_ref(), + &Default::default(), + None, + &title, + &spotify_mpv_options(), + ) + .map_err(|e| e.to_string())?; + let _ = self.player.play(); + controls.play(); + } else { + self.player.seek(position).map_err(|e| e.to_string())?; + } if self.lt.is_host().await { self.lt .broadcast_playback(Playback::at(PlaybackKind::Seek, (position * 1000.0) as i64)) @@ -2638,7 +3299,7 @@ impl AppState { let gen = self.generation.load(Ordering::SeqCst); self.prime_lookahead(gen).await; } - let _ = self.app.emit("stop-after-current", enabled); + self.emit("stop-after-current", enabled); } pub async fn set_repeat(self: &std::sync::Arc, mode: RepeatMode) { @@ -2649,7 +3310,7 @@ impl AppState { // Repeat-one and a one-shot EOF stop cannot both complete. A deliberate repeat-one click // wins and disarms the stop; repeat-all/off preserve it but do not re-prime while armed. if mode == RepeatMode::One && self.stop_after_current.swap(false, Ordering::SeqCst) { - let _ = self.app.emit("stop-after-current", false); + self.emit("stop-after-current", false); } { let mut q = self.queue.lock().await; @@ -2993,7 +3654,7 @@ fn now_ms() -> i64 { .unwrap_or(0) } -pub(crate) fn song_to_track(s: &SongItem) -> Track { +pub fn song_to_track(s: &SongItem) -> Track { Track { id: s.video_id.clone(), title: s.title.clone(), @@ -3362,6 +4023,32 @@ pub fn saved_volume(db: &Db) -> i64 { v.filter(|v| (0..=100).contains(v)).unwrap_or(100) } +/// Read the personal blob out of the DB, degrading anything that is missing, unparseable, or not a +/// JSON object to `{}`. The clients hydrate tolerantly on top of this, so a corrupt store costs the +/// user their shortcuts, never a crash. +fn read_personal(db: &Db) -> serde_json::Value { + db.get_setting(PERSONAL_JSON_KEY) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})) +} + +/// Validate a personal blob and return its serialized form. Rejects a non-object (the store is one +/// keyed record, never a bare array/scalar) and anything over [`MAX_PERSONAL_BYTES`]. +fn validate_personal_blob(blob: &serde_json::Value) -> Result { + if !blob.is_object() { + return Err("personal store must be a JSON object".into()); + } + let json = serde_json::to_string(blob).map_err(|e| e.to_string())?; + if json.len() > MAX_PERSONAL_BYTES { + return Err(format!( + "personal store too large: {} bytes (max {MAX_PERSONAL_BYTES})", + json.len() + )); + } + Ok(json) +} + /// Per-track loudness gain (dB) from YouTube's `loudnessDb`. Attenuate /// only toward reference loudness: loud masters get pulled down, quieter tracks aren't boosted, /// so there's no clipping and no limiter to add. @@ -3381,6 +4068,36 @@ fn loudness_gain(loudness_db: Option) -> Option { (gain < -0.05).then(|| gain.max(-24.0)) } +/// "Artist – Title" for mpv's per-file media title (what PipeWire and mpv's own metadata show). +fn media_title(title: &str, artists: &str) -> String { + match (title.trim(), artists.trim()) { + ("", "") => "Ryotunes".to_owned(), + (t, "") => t.to_owned(), + ("", a) => a.to_owned(), + (t, a) => format!("{a} – {t}"), + } +} + +/// The per-file mpv options for a Spotify track. librespot writes raw S16LE / 44100 Hz / stereo +/// PCM into a FIFO that mpv can't probe, so the rawaudio demuxer must be told the exact format. +/// Must stay byte-for-byte in step with the format librespot's `pipe` backend emits. +fn spotify_mpv_options() -> Vec { + vec![ + "demuxer=rawaudio".to_owned(), + "demuxer-rawaudio-format=s16le".to_owned(), + "demuxer-rawaudio-rate=44100".to_owned(), + "demuxer-rawaudio-channels=2".to_owned(), + ] +} + +/// True track position from mpv's per-load `time-pos` and the Spotify seek base. mpv restarts its +/// clock at 0 on every FIFO (re)load, so a seek stores the target as the base and playback reports +/// `base + time-pos`. The base is 0 for every non-Spotify track and for an un-seeked Spotify one, +/// making this transparent there. +fn position_with_base(mpv_time_pos: f64, seek_base: f64) -> f64 { + mpv_time_pos + seek_base +} + /// Loudness target, matching YouTube Music's own player rather than the video site's -14. const TARGET_LUFS: f64 = -7.0; @@ -3423,14 +4140,213 @@ fn persist_fingerprint(q: &QueueState) -> u64 { hasher.finish() } +/// Thin metadata/browse/player API the host commands forward into. Each body is the logic that +/// used to live inline in a `#[tauri::command]`, moved here so the host stays a pure adapter. +impl AppState { + fn metadata_client(&self) -> Result<&innertube::YouTubeClient, String> { + self.clients.get(innertube::METADATA_CLIENT).ok_or_else(|| "metadata client missing".into()) + } + + pub async fn search(&self, query: &str) -> Result, String> { + let client = self.metadata_client()?; + let result = self.it.search_songs(client, query).await.map_err(|e| e.to_string())?; + Ok(result.items) + } + + pub async fn search_page(&self, query: &str) -> Result { + let client = self.metadata_client()?; + self.it.search_songs(client, query).await.map_err(|e| e.to_string()) + } + + pub async fn search_page_more(&self, token: &str) -> Result { + let client = self.metadata_client()?; + self.it.search_songs_continuation(client, token).await.map_err(|e| e.to_string()) + } + + pub async fn search_all(&self, query: &str) -> Result { + let client = self.metadata_client()?; + self.it.search_all(client, query).await.map_err(|e| e.to_string()) + } + + pub async fn search_cards( + &self, + query: &str, + category: &str, + ) -> Result, String> { + let client = self.metadata_client()?; + self.it.search_cards(client, query, category).await.map_err(|e| e.to_string()) + } + + pub async fn search_cards_page( + &self, + query: &str, + category: &str, + ) -> Result { + let client = self.metadata_client()?; + self.it.search_cards_page(client, query, category).await.map_err(|e| e.to_string()) + } + + pub async fn search_cards_more( + &self, + token: &str, + ) -> Result { + let client = self.metadata_client()?; + self.it.search_cards_continuation(client, token).await.map_err(|e| e.to_string()) + } + + pub async fn search_all_more(&self, token: &str) -> Result { + let client = self.metadata_client()?; + self.it.search_all_continuation(client, token).await.map_err(|e| e.to_string()) + } + + pub async fn home(&self, params: Option<&str>) -> Result { + let client = self.metadata_client()?; + self.it.home(client, params).await.map_err(|e| e.to_string()) + } + + pub async fn home_more(&self, token: &str) -> Result { + let client = self.metadata_client()?; + self.it.home_continuation(client, token).await.map_err(|e| e.to_string()) + } + + /// Signed out there is no YouTube library grid, and the Library page merges local saves in, so + /// "nothing of yours on YouTube" is an empty answer rather than an error. + pub async fn library_albums(&self) -> Result, String> { + if !self.it.is_logged_in() { + return Ok(Vec::new()); + } + let client = self.metadata_client()?; + self.it.library_albums(client).await.map_err(|e| e.to_string()) + } + + pub async fn library_artists(&self) -> Result, String> { + if !self.it.is_logged_in() { + return Ok(Vec::new()); + } + let client = self.metadata_client()?; + self.it.library_artists(client).await.map_err(|e| e.to_string()) + } + + pub async fn playlist_more( + &self, + token: &str, + ) -> Result { + let client = self.metadata_client()?; + self.it.playlist_continuation(client, token).await.map_err(|e| e.to_string()) + } + + /// A YouTube album page, or a local album/artist page built from SQLite so it opens offline. + pub async fn album(&self, id: &str) -> Result { + if let Some(key) = id.strip_prefix(crate::local::ALBUM_PREFIX) { + return Ok(crate::local::album_page(&self.db, key)); + } + if let Some(name) = id.strip_prefix(crate::local::ARTIST_PREFIX) { + return Ok(crate::local::artist_page(&self.db, name)); + } + let client = self.metadata_client()?; + self.it.album(client, id).await.map_err(|e| e.to_string()) + } + + pub async fn artist(&self, id: &str) -> Result { + let client = self.metadata_client()?; + self.it.artist(client, id).await.map_err(|e| e.to_string()) + } + + pub async fn browse_grid( + &self, + id: &str, + params: Option<&str>, + ) -> Result, String> { + let client = self.metadata_client()?; + self.it.browse_grid(client, id, params).await.map_err(|e| e.to_string()) + } + + pub fn set_volume(&self, volume: i64) -> Result<(), String> { + if !(0..=100).contains(&volume) { + return Err("Volume must be between 0 and 100.".into()); + } + self.player.set_volume(volume).map_err(|e| e.to_string())?; + // There is one volume and there can be two windows (the mini player). Without this the one + // that didn't move the slider keeps showing the old level and lies about what you're hearing. + self.emit("volume", volume); + Ok(()) + } + + pub fn set_playback_params(&self, speed: f64, semitones: i32) -> Result<(), String> { + // The tempo/pitch steppers are the two oldest fields of the fx set; a change to either + // keeps whatever reverb/bass/width the Sound dialog left in place. + self.set_audio_fx(AudioFx { speed, semitones, ..self.audio_fx() }) + } + + /// The current audio-fx values, for a snapshot or the Sound dialog opening cold. + pub fn audio_fx(&self) -> AudioFx { + *self.audio_fx.read() + } + + /// Apply the Sound dialog's effects. Ranges are validated here (the daemon is the one place + /// that must not trust a client), applied to the player as one `af` chain, then — only after a + /// successful apply — stored and echoed to every client as `audio-fx` so all windows agree. + pub fn set_audio_fx(&self, fx: AudioFx) -> Result<(), String> { + if !fx.speed.is_finite() || !(0.25..=2.0).contains(&fx.speed) { + return Err("Tempo must be between 0.25× and 2.00×.".into()); + } + if !(-12..=12).contains(&fx.semitones) { + return Err("Pitch must be between -12 and +12 semitones.".into()); + } + if !fx.reverb.is_finite() || !(0.0..=1.0).contains(&fx.reverb) { + return Err("Reverb must be between 0 and 1.".into()); + } + if !fx.bass_db.is_finite() || !(-6.0..=12.0).contains(&fx.bass_db) { + return Err("Bass must be between -6 and +12 dB.".into()); + } + if !fx.width.is_finite() || !(0.0..=1.0).contains(&fx.width) { + return Err("Width must be between 0 and 1.".into()); + } + self.player.set_fx(&fx).map_err(|e| e.to_string())?; + *self.audio_fx.write() = fx; + self.emit("audio-fx", fx); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::{ append_page, backfill_metadata, drop_duplicates, enqueue_at, format_duration, guest_insert_index, is_mix, loudness_gain, merge_radio, next_index, parse_duration_ms, - persist_fingerprint, queue_fingerprint, radio_seed_for, shuffle_new_queue, - shuffle_upcoming, splice_radio_into, unshuffled, upcoming_queued, QueueState, RepeatMode, + persist_fingerprint, position_with_base, queue_fingerprint, radio_seed_for, read_personal, + shuffle_new_queue, shuffle_upcoming, splice_radio_into, spotify_mpv_options, unshuffled, + upcoming_queued, validate_personal_blob, QueueState, RepeatMode, PERSONAL_JSON_KEY, }; + use crate::db::Db; + + #[test] + fn personal_round_trips_and_rejects_bad_blobs() { + let db = Db::open(std::path::Path::new(":memory:")).unwrap(); + // Nothing saved yet reads as an empty object, not null or an error. + assert_eq!(read_personal(&db), serde_json::json!({})); + // A real store round-trips through SQLite byte for byte. + let blob = serde_json::json!({ + "picks": [{ "id": "VL123", "kind": "playlist", "title": "Mix", "lastUsedAt": 42 }], + "pins": ["VL123"], + "recent": { "VL123": { "id": "VL123", "kind": "playlist", "at": 99 } }, + "home": { "order": ["@recent", "@familiar"], "hidden": [], "seen": ["Listen again"] } + }); + db.set_setting(PERSONAL_JSON_KEY, &validate_personal_blob(&blob).unwrap()); + assert_eq!(read_personal(&db), blob); + // A non-object is refused: the store is one keyed record, never a bare array or scalar. + assert!(validate_personal_blob(&serde_json::json!([1, 2, 3])).is_err()); + assert!(validate_personal_blob(&serde_json::json!("nope")).is_err()); + assert!(validate_personal_blob(&serde_json::json!(7)).is_err()); + // Over a MiB is refused so a corrupt client can't wedge the store. + let huge = serde_json::json!({ "x": "y".repeat(1024 * 1024) }); + assert!(validate_personal_blob(&huge).is_err()); + // A stored value that somehow isn't an object degrades to {} rather than propagating. + db.set_setting(PERSONAL_JSON_KEY, "[1,2,3]"); + assert_eq!(read_personal(&db), serde_json::json!({})); + db.set_setting(PERSONAL_JSON_KEY, "not json at all"); + assert_eq!(read_personal(&db), serde_json::json!({})); + } #[test] fn queue_fingerprint_tracks_the_rows_not_their_metadata() { @@ -4078,4 +4994,45 @@ mod tests { // No metadata → no filter. assert_eq!(loudness_gain(None), None); } + + // The FIFO carries raw PCM mpv can't probe; these exact options are the whole contract with + // librespot's `pipe` backend (S16LE / 44100 / stereo). A typo would fail playback silently. + #[test] + fn spotify_mpv_options_are_the_rawaudio_demuxer_settings() { + assert_eq!( + spotify_mpv_options(), + vec![ + "demuxer=rawaudio", + "demuxer-rawaudio-format=s16le", + "demuxer-rawaudio-rate=44100", + "demuxer-rawaudio-channels=2", + ] + ); + } + + // mpv's `time-pos` restarts at 0 every time the Spotify FIFO is (re)loaded, so a reported + // position is `mpv time-pos + seek base`. Base 0 (every non-Spotify track, and a Spotify track + // that was never seeked) is transparent; a seek to 60s makes mpv's post-reload 0/0.5 read back + // as 60/60.5 — the true track position, not a jump back to the start. + #[test] + fn spotify_seek_base_reconstructs_true_position() { + assert_eq!(position_with_base(12.5, 0.0), 12.5); + assert_eq!(position_with_base(0.0, 60.0), 60.0); + assert_eq!(position_with_base(0.5, 60.0), 60.5); + } + + // Real Spotify playback needs a Premium session and the librespot pipe backend, which can't run + // in CI (no credentials, no audio device). This documents the manual acceptance path; run it + // by hand on the rig against a signed-in Premium account. See `crates/spotify/README.md`. + #[test] + #[ignore = "needs a real Spotify Premium session + audio device; run manually on the rig"] + fn spotify_premium_playback_manual() { + // 1. Sign in to a Premium account (daemon `spotify_sign_in`), select the Spotify provider. + // 2. Play a `spotify:track:` item: `resolve` opens a librespot stream, mpv reads the FIFO + // with `spotify_mpv_options()`, audio plays; duration is seeded from the queue item. + // 3. Seek: librespot seeks, mpv reloads the FIFO, the position reads `base + time-pos`. + // 4. Pause/resume: mpv only — FIFO backpressure stalls librespot, the pipe stays open. + // 5. Let it end: librespot closes the FIFO, mpv EOFs, `on_track_ended` advances the queue. + // 6. Play while signed out: `resolve` returns `SignInRequired` and the UI toasts it. + } } diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs new file mode 100644 index 0000000..1545417 --- /dev/null +++ b/crates/core/src/update.rs @@ -0,0 +1,393 @@ +//! Explicit, package-managed updates shared by both desktop hosts. GitHub is the release +//! authority; the renderer can select a version, never a URL, command or destination. +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +const REPOSITORY: &str = "https://github.com/ryoku-dev/ryotunes"; +const LATEST: &str = "https://api.github.com/repos/ryoku-dev/ryotunes/releases/latest"; +const MAX_PACKAGE: u64 = 1024 * 1024 * 1024; +static INSTALL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: Option, + pub available: bool, + pub release_url: String, + pub notes: String, + pub can_install: bool, + pub unsupported_reason: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledUpdate { + pub version: String, + pub restart_required: bool, +} + +#[derive(Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + body: Option, + assets: Vec, +} + +#[derive(Deserialize)] +struct Asset { + name: String, + size: u64, +} + +// Canonical v1 versions only: no traversal, prereleases, leading zeroes or legacy v2 tags. +fn version(raw: &str) -> Option<(u64, u64, u64)> { + let mut parts = raw.split('.'); + let (major, minor, patch) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() || major != "1" { + return None; + } + if (minor.len() > 1 && minor.starts_with('0')) + || !minor.bytes().all(|byte| byte.is_ascii_digit()) + || patch.len() != 1 + || !patch.as_bytes()[0].is_ascii_digit() + { + return None; + } + Some((1, minor.parse::().ok()?, (patch.as_bytes()[0] - b'0') as u64)) +} + +fn package_name(version: &str) -> String { + format!("ryotunes-{version}-1-x86_64.pkg.tar.zst") +} + +fn release_info(release: Option, current: &str, reason: Option) -> UpdateInfo { + let mut info = UpdateInfo { + current_version: current.into(), + latest_version: None, + available: false, + release_url: format!("{REPOSITORY}/releases"), + notes: String::new(), + can_install: false, + unsupported_reason: reason, + }; + let Some(release) = release else { return info }; + let Some(latest) = release.tag_name.strip_prefix('v') else { return info }; + let Some(next) = version(latest) else { return info }; + if release.draft || release.prerelease { + return info; + } + info.latest_version = Some(latest.into()); + // v1 is a deliberate new release series, not a numeric downgrade of the legacy v2 line. + info.available = version(current).is_none_or(|installed| next > installed); + info.release_url = format!("{REPOSITORY}/releases/tag/v{latest}"); + info.notes = release.body.unwrap_or_default(); + let name = package_name(latest); + let checksum = format!("{name}.sha256"); + let has_package = + release.assets.iter().any(|a| a.name == name && a.size > 0 && a.size <= MAX_PACKAGE); + let has_checksum = + release.assets.iter().any(|a| a.name == checksum && a.size > 0 && a.size <= 4096); + if !has_package || !has_checksum { + info.unsupported_reason = Some("This release has no complete x86_64 Arch package and checksum. View the changelog or check again after the release build finishes.".into()); + } + info.can_install = info.unsupported_reason.is_none() && has_package && has_checksum; + info +} + +async fn response(url: &str, timeout: Duration) -> Result { + crate::http::client() + .get(url) + .header(reqwest::header::USER_AGENT, concat!("Ryotunes/", env!("CARGO_PKG_VERSION"))) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .timeout(timeout) + .send() + .await + .map_err(|e| format!("Could not contact GitHub: {e}")) +} + +async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result, String> { + response = response.error_for_status().map_err(|e| format!("GitHub request failed: {e}"))?; + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + if chunk.len() > limit.saturating_sub(bytes.len()) { + return Err("GitHub response exceeded the allowed size.".into()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +async fn installation_reason() -> Option { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { + return Some("Self update supports the x86_64 Arch/Ryoku package. Download a release for your platform instead.".into()); + } + if !Path::new("/usr/bin/pacman").is_file() || !Path::new("/usr/bin/pkexec").is_file() { + return Some( + "Self update needs pacman and polkit (pkexec), with a desktop authentication agent." + .into(), + ); + } + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(e) => return Some(format!("Cannot determine the running installation: {e}")), + }; + let owner = Command::new("/usr/bin/pacman").args(["-Qoq"]).arg(exe).kill_on_drop(true).output(); + match tokio::time::timeout(Duration::from_secs(10), owner).await { + Ok(Ok(output)) if output.status.success() && output.stdout == b"ryotunes\n" => None, + _ => Some("This is not a pacman-managed Ryotunes installation. Install the release package first; development builds are never overwritten.".into()), + } +} + +pub async fn check_for_updates() -> Result { + let response = response(LATEST, Duration::from_secs(30)).await?; + let release = if response.status() == reqwest::StatusCode::NOT_FOUND { + None + } else { + let body = bounded_body(response, 1024 * 1024).await?; + Some( + serde_json::from_slice::(&body) + .map_err(|e| format!("Invalid GitHub release metadata: {e}"))?, + ) + }; + Ok(release_info(release, env!("CARGO_PKG_VERSION"), installation_reason().await)) +} + +fn checksum(bytes: &[u8], name: &str) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| "Invalid package checksum encoding.")?; + let mut fields = text.split_whitespace(); + let hash = fields.next().unwrap_or_default(); + if fields.next() != Some(name) + || fields.next().is_some() + || hash.len() != 64 + || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err( + "Invalid package checksum: expected one SHA-256 entry for this release package.".into(), + ); + } + Ok(hash.to_ascii_lowercase()) +} + +async fn download(url: &str, path: &Path, expected: &str) -> Result<(), String> { + let mut response = response(url, Duration::from_secs(15 * 60)) + .await? + .error_for_status() + .map_err(|e| format!("Package download failed: {e}"))?; + if response.content_length().is_some_and(|length| length > MAX_PACKAGE) { + return Err("Release package is too large.".into()); + } + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .await + .map_err(|e| format!("Could not stage update: {e}"))?; + let mut size = 0u64; + let mut hash = Sha256::new(); + while let Some(chunk) = + response.chunk().await.map_err(|e| format!("Package download interrupted: {e}"))? + { + size += chunk.len() as u64; + if size > MAX_PACKAGE { + return Err("Release package is too large.".into()); + } + hash.update(&chunk); + file.write_all(&chunk).await.map_err(|e| format!("Could not write update: {e}"))?; + } + if size == 0 || format!("{:x}", hash.finalize()) != expected { + return Err("Package SHA-256 verification failed. Nothing was installed.".into()); + } + file.sync_all().await.map_err(|e| format!("Could not save update: {e}"))?; + Ok(()) +} + +pub async fn install_update(requested: String) -> Result { + if version(&requested).is_none() { + return Err("Select a valid v1 release.".into()); + } + let _guard = INSTALL.try_lock().map_err(|_| "An update is already in progress.")?; + let info = check_for_updates().await?; + if info.latest_version.as_deref() != Some(&requested) { + return Err( + "The latest release changed. Check for new versions again before updating.".into() + ); + } + if !info.available { + return Err("This version is already running, or a newer version is installed.".into()); + } + if !info.can_install { + return Err(info + .unsupported_reason + .unwrap_or_else(|| "Self update is not available for this installation.".into())); + } + // Ryoku may already have upgraded the files while this older daemon was still running. + let installed = tokio::time::timeout( + Duration::from_secs(10), + Command::new("/usr/bin/pacman").args(["-Q", "ryotunes"]).kill_on_drop(true).output(), + ) + .await + .map_err(|_| "Timed out reading the installed package.")? + .map_err(|e| format!("Could not query the installed package: {e}"))?; + if !installed.status.success() { + return Err("Could not query the installed Ryotunes package.".into()); + } + let installed_text = String::from_utf8_lossy(&installed.stdout); + if let Some(installed_version) = installed_text + .trim() + .strip_prefix("ryotunes 1:") + .and_then(|value| value.rsplit_once('-').map(|(v, _)| v)) + { + if version(installed_version) > version(&requested) { + return Err( + "A newer package is already installed. Quit Ryotunes and open it again.".into() + ); + } + if installed_version == requested { + return Ok(InstalledUpdate { version: requested, restart_required: true }); + } + } + // Private random staging, removed on every success/error. No user-controlled path reaches root. + let staging = tempfile::Builder::new() + .prefix("ryotunes-update-") + .tempdir() + .map_err(|e| format!("Could not create private update staging: {e}"))?; + let name = package_name(&requested); + let url = format!("{REPOSITORY}/releases/download/v{requested}/{name}"); + let expected = checksum( + &bounded_body(response(&format!("{url}.sha256"), Duration::from_secs(30)).await?, 4096) + .await?, + &name, + )?; + let path = staging.path().join(&name); + download(&url, &path, &expected).await?; + + // Verify the package's internal identity, not just its download filename, before privilege. + let metadata = + Command::new("/usr/bin/pacman").arg("-Qp").arg(&path).kill_on_drop(true).output(); + let output = tokio::time::timeout(Duration::from_secs(30), metadata) + .await + .map_err(|_| "Timed out reading the package identity.")? + .map_err(|e| format!("Could not inspect the package: {e}"))?; + let expected_identity = format!("ryotunes 1:{requested}-1\n"); + if !output.status.success() || output.stdout != expected_identity.as_bytes() { + return Err( + "Downloaded package identity does not match the selected Ryotunes release.".into() + ); + } + // Explicit Update click + polkit approval authorizes just this package transaction. Retain + // pacman's signature/dependency policy; never use --overwrite, --nodeps or bypass signatures. + // Do not kill pacman on a timeout: interrupting a committed transaction can damage the install. + let output = Command::new("/usr/bin/pkexec") + .arg("/usr/bin/pacman") + .args(["-U", "--noconfirm"]) + .arg(&path) + .stdin(Stdio::null()) + .output() + .await + .map_err(|e| format!("Could not start the administrator approval: {e}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "Update was cancelled or pacman could not install the package ({}). {}", + output.status, + detail.trim() + )); + } + Ok(InstalledUpdate { version: requested, restart_required: true }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(tag: &str) -> Release { + let name = package_name(tag.trim_start_matches('v')); + Release { + tag_name: tag.into(), + draft: false, + prerelease: false, + body: Some("Changes".into()), + assets: vec![ + Asset { name: name.clone(), size: 100 }, + Asset { name: format!("{name}.sha256"), size: 120 }, + ], + } + } + + #[test] + fn release_series_and_numeric_order() { + assert!(release_info(Some(release("v1.1.0")), "1.0.9", None).available); + assert!(!release_info(Some(release("v1.0.9")), "1.1.0", None).available); + assert!(!release_info(Some(release("v1.1.0")), "1.1.0", None).available); + assert!(release_info(Some(release("v1.0.0")), "2.5.1", None).available); + assert!(release_info(Some(release("v2.5.1")), "1.0.0", None).latest_version.is_none()); + assert!(version("1.0.10").is_none()); + assert!(version("1.01.0").is_none()); + assert!(version("1.0.0/../../bad").is_none()); + } + + #[test] + fn incomplete_or_unpublished_releases_cannot_install() { + let mut value = release("v1.0.1"); + value.assets.pop(); + let info = release_info(Some(value), "1.0.0", None); + assert!(info.available && !info.can_install); + let mut value = release("v1.0.1"); + value.prerelease = true; + assert!(release_info(Some(value), "1.0.0", None).latest_version.is_none()); + assert!( + !release_info(Some(release("v1.0.1")), "1.0.0", Some("Unmanaged".into())).can_install + ); + } + + #[tokio::test] + async fn downloaded_bytes_must_match_and_never_replace_an_existing_file() { + use tokio::io::AsyncReadExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/package", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for _ in 0..3 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + stream.read(&mut request).await.unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\npackage", + ) + .await + .unwrap(); + } + }); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("verified"); + let hash = format!("{:x}", Sha256::digest(b"package")); + download(&url, &path, &hash).await.unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"package"); + assert!(download(&url, &dir.path().join("corrupt"), &"0".repeat(64)) + .await + .unwrap_err() + .contains("SHA-256")); + tokio::fs::write(&path, b"keep").await.unwrap(); + assert!(download(&url, &path, &hash).await.is_err()); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"keep"); + server.await.unwrap(); + } + + #[test] + fn checksum_binds_exactly_one_asset() { + let hash = "a".repeat(64); + assert_eq!(checksum(format!("{hash} package\n").as_bytes(), "package").unwrap(), hash); + assert!(checksum(format!("{hash} other\n").as_bytes(), "package").is_err()); + assert!(checksum(format!("{hash} package\n{hash} other").as_bytes(), "package").is_err()); + assert!(checksum(b"not-a-hash package", "package").is_err()); + } +} diff --git a/crates/innertube/src/models/player.rs b/crates/innertube/src/models/player.rs index c080168..3673793 100644 --- a/crates/innertube/src/models/player.rs +++ b/crates/innertube/src/models/player.rs @@ -100,6 +100,17 @@ impl PlayabilityStatus { | "CONTENT_CHECK_REQUIRED" ) } + /// YouTube's anti-bot gate: a `LOGIN_REQUIRED` whose reason asks the (anonymous) client to + /// prove it isn't a bot. Distinct from a genuine age/login gate — it is answered by carrying a + /// valid `visitorData` (or signing in), not by the WEB_CREATOR retry, so the orchestrator + /// treats it as "our session identity is missing/stale" and re-bootstraps it. + pub fn is_bot_gate(&self) -> bool { + self.status == "LOGIN_REQUIRED" + && self.reason.as_deref().is_some_and(|r| { + let r = r.to_ascii_lowercase(); + r.contains("not a bot") || r.contains("confirm you") + }) + } } #[derive(Debug, Clone, Deserialize)] @@ -403,6 +414,24 @@ mod tests { assert_eq!(ugc.is_music_video(), Some(true)); assert_eq!(bare.is_music_video(), None); } + + /// The bot gate must be told from a genuine login/age gate: only the former is answered by + /// a fresh visitorData, and the orchestrator re-bootstraps the session on it. + #[test] + fn bot_gate_is_the_login_required_why_not_a_bot() { + let gate = |status: &str, reason: Option<&str>| PlayabilityStatus { + status: status.into(), + reason: reason.map(Into::into), + }; + assert!(gate("LOGIN_REQUIRED", Some("Sign in to confirm you're not a bot")).is_bot_gate()); + assert!(gate("LOGIN_REQUIRED", Some("Please sign in to confirm you're not a bot")) + .is_bot_gate()); + // A real login/age gate: nothing to do with visitorData. + assert!(!gate("LOGIN_REQUIRED", Some("This video is unavailable")).is_bot_gate()); + assert!(!gate("LOGIN_REQUIRED", None).is_bot_gate()); + assert!(!gate("UNPLAYABLE", Some("Sign in to confirm you're not a bot")).is_bot_gate()); + assert!(!gate("OK", Some("you're not a bot")).is_bot_gate()); + } } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml index 36e2bbb..6bddd96 100644 --- a/crates/player/Cargo.toml +++ b/crates/player/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } thiserror = { workspace = true } +serde = { workspace = true } +parking_lot = "0.12" # libmpv2 is the maintained fork of the abandoned `libmpv` crate. libmpv2 = "4" libc = "0.2" diff --git a/crates/player/examples/play.rs b/crates/player/examples/play.rs index 02b802d..1c8805a 100644 --- a/crates/player/examples/play.rs +++ b/crates/player/examples/play.rs @@ -17,8 +17,8 @@ async fn main() { let mut p = Player::new(cache.to_str().unwrap()).expect("player"); let mut events = p.take_events().unwrap(); - p.load(&a, &HashMap::new(), None).expect("load A"); - p.enqueue(&b).expect("enqueue B"); + p.load(&a, &HashMap::new(), None, "A", &[]).expect("load A"); + p.enqueue(&b, "B").expect("enqueue B"); p.play().expect("play"); let mut ended = 0; diff --git a/crates/player/examples/title.rs b/crates/player/examples/title.rs new file mode 100644 index 0000000..c62a997 --- /dev/null +++ b/crates/player/examples/title.rs @@ -0,0 +1,29 @@ +//! Media-title probe: `cargo run -p player --example title -- [title]` +//! Loads a file with an awkward per-file title (spaces, commas, quotes, non-ASCII) and asserts +//! that mpv reports it back verbatim. + +use std::collections::HashMap; + +use player::Player; + +fn main() { + let a = std::env::args().nth(1).expect("usage: cargo run -p player --example title -- "); + let cache = std::env::temp_dir().join("ryotunes-player-example"); + std::fs::create_dir_all(&cache).ok(); + let p = Player::new(cache.to_str().unwrap()).expect("player"); + let title = std::env::args() + .nth(2) + .unwrap_or_else(|| "Ünïcode Artist, \"quoted\" – A Title".to_owned()); + p.load(&a, &HashMap::new(), None, &title, &[]).expect("load"); + let mut got = String::new(); + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(100)); + got = p.media_title().unwrap_or_default(); + if !got.is_empty() { + break; + } + } + println!("want: {title}"); + println!("got: {got}"); + assert_eq!(got, title); +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 52dde9a..bb364c4 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -62,6 +62,33 @@ fn friendly_error(e: &libmpv2::Error) -> String { } } +/// The user-tunable audio effects the Sound dialog drives (spec section 7). Applied as one mpv +/// `af` chain alongside the loudness gain; `speed` is mpv's `speed` property, not part of the +/// chain. Serialized `{speed, semitones, reverb, bass, width}` for the socket — `bass_db` is +/// `bass` on the wire. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AudioFx { + /// Tempo multiplier, 0.25–2.0 (mpv `speed`: time-stretch, pitch preserved). + pub speed: f64, + /// Pitch shift in semitones, −12..=12, via the rubberband filter. + pub semitones: i32, + /// Reverb depth 0..1: scales the echo decays; 0 omits the filter. + pub reverb: f64, + /// Bass shelf gain, −6..+12 dB; 0 omits the filter. + #[serde(rename = "bass")] + pub bass_db: f64, + /// Stereo width 0..1; 0 omits the filter. + pub width: f64, +} + +impl Default for AudioFx { + /// Everything off: speed 1×, no pitch shift, no effects — the same filterless path playback + /// took before the Sound dialog existed. + fn default() -> Self { + AudioFx { speed: 1.0, semitones: 0, reverb: 0.0, bass_db: 0.0, width: 0.0 } + } +} + /// The player. Wraps `Arc` (Send+Sync); the event loop runs on a dedicated OS thread and /// pumps [`PlayerEvent`]s into a channel taken once via [`Player::take_events`]. pub struct Player { @@ -70,10 +97,22 @@ pub struct Player { /// Event-driven mirror of mpv `idle-active`. Lifecycle code must not synchronously query mpv /// from the async event pump: that can race/stall during pause and gapless transitions. idle_active: Arc, - /// `(loudness gain dB, pitch semitones)`. mpv's `af` is one global chain, so the two things - /// that write to it have to be re-applied together: a bare `set_property("af", ...)` from - /// either one would drop the other's filter. - af: std::sync::Mutex<(Option, i32)>, + event_stop: Arc, + event_thread: Option>, + /// `(loudness gain dB, audio-fx)`. mpv's `af` is one global chain, so everything that writes + /// to it — the loudness gain and every effect in [`AudioFx`] — has to be re-applied together: + /// a bare `set_property("af", ...)` from any one of them would drop the others' filters. + af: parking_lot::Mutex<(Option, AudioFx)>, +} + +impl Drop for Player { + fn drop(&mut self) { + // EventContext borrows mpv's raw handle. Stop its reader before destroying that handle. + self.event_stop.store(true, Ordering::Release); + if let Some(thread) = self.event_thread.take() { + let _ = thread.join(); + } + } } impl Player { @@ -109,12 +148,21 @@ impl Player { ev.observe_property("idle-active", Format::Flag, 3)?; let event_idle_active = idle_active.clone(); - std::thread::Builder::new() + let event_stop = Arc::new(AtomicBool::new(false)); + let stop = event_stop.clone(); + let event_thread = std::thread::Builder::new() .name("mpv-events".into()) - .spawn(move || event_loop(ev, tx, event_idle_active)) + .spawn(move || event_loop(ev, tx, event_idle_active, stop)) .expect("spawn mpv event thread"); - Ok(Player { mpv, events: Some(rx), idle_active, af: std::sync::Mutex::new((None, 0)) }) + Ok(Player { + mpv, + events: Some(rx), + idle_active, + event_stop, + event_thread: Some(event_thread), + af: parking_lot::Mutex::new((None, AudioFx::default())), + }) } /// Take the event receiver (once). @@ -122,16 +170,21 @@ impl Player { self.events.take() } - /// Load and play a fresh URL, replacing the playlist. + /// Load and play a fresh URL, replacing the playlist. `title` names the stream for mpv + /// (and so for the PipeWire node); without it mpv uses the URL, which for a signed + /// googlevideo link puts the whole token into the audio graph. pub fn load( &self, url: &str, headers: &HashMap, gain_db: Option, + title: &str, + options: &[String], ) -> Result<(), Error> { self.apply_headers(headers)?; self.set_gain(gain_db)?; - self.mpv.command("loadfile", &["ed(url), "replace"])?; + self.mpv + .command("loadfile", &["ed(url), "replace", "-1", &file_options(title, options)])?; // Close→pause can happen before mpv's property notification reaches the event thread. // Mark the session loaded immediately so background lifecycle can never mistake that tiny // transition window for an empty player. The observed property corrects this on failure/EOF. @@ -144,8 +197,8 @@ impl Player { /// Note: mpv's `http-header-fields`/`user-agent` are global properties, so appended tracks /// inherit the currently-set headers. Ordinary direct-URL streams do not require per-track /// cookies; upload-specific headers are handled before a track is loaded. - pub fn enqueue(&self, url: &str) -> Result<(), Error> { - self.mpv.command("loadfile", &["ed(url), "append"])?; + pub fn enqueue(&self, url: &str, title: &str) -> Result<(), Error> { + self.mpv.command("loadfile", &["ed(url), "append", "-1", &title_option(title)])?; Ok(()) } @@ -162,6 +215,11 @@ impl Player { self.mpv.get_property::("idle-active").unwrap_or(true) } + /// The title mpv reports for the current file (the per-file `force-media-title` once set). + pub fn media_title(&self) -> Option { + self.mpv.get_property::("media-title").ok() + } + /// Cheap event-driven loaded-media state for background lifecycle decisions. Unlike /// [`Self::is_idle`], this never takes mpv's core lock and therefore cannot turn a transient /// property-query failure during Pause into a false "nothing loaded" result. @@ -249,7 +307,7 @@ impl Player { // round-trip (a few ms) and the filter chain reinits mid-stream. If that ever clicks audibly, // keep one labelled filter (`af=@gain:lavfi=[volume=0dB]`) and retune it with `af-command`. pub fn set_gain(&self, gain_db: Option) -> Result<(), Error> { - self.af.lock().unwrap().0 = gain_db; + self.af.lock().0 = gain_db; self.apply_af() } @@ -260,53 +318,78 @@ impl Player { Ok(()) } - /// Pitch shift in semitones, −12..=12 (one octave either way), via the rubberband filter. - /// Independent of [`Self::set_speed`]: rubberband takes over the time-stretch mpv would - /// otherwise do with scaletempo2, and shifts pitch on top of it. - // Note: native `rubberband` only. A libmpv built without librubberband errors out and the - // command surfaces that to the user; wire the `lavfi=[rubberband=pitch=...]` fallback if a - // Windows/macOS build ever turns up without it. - pub fn set_pitch(&self, semitones: i32) -> Result<(), Error> { - let wanted = semitones.clamp(-12, 12); - let previous = std::mem::replace(&mut self.af.lock().unwrap().1, wanted); + /// Apply the full [`AudioFx`] set. Speed is mpv's own `speed` property; the pitch shift and + /// the reverb/bass/width filters share the one `af` chain, applied together with the loudness + /// gain. Pitch is the only part that can fail — a libmpv built without librubberband rejects + /// the whole chain — so a rejection rolls the fx back to what was playing and re-applies it, + /// exactly as the old pitch setter did, and surfaces [`Error::NoPitchFilter`] to the user. + // Note: native `rubberband` only. If a Windows/macOS build ever turns up without it, wire the + // `lavfi=[rubberband=pitch=...]` fallback here. + pub fn set_fx(&self, fx: &AudioFx) -> Result<(), Error> { + self.set_speed(fx.speed)?; + let previous = std::mem::replace(&mut self.af.lock().1, *fx); if let Err(e) = self.apply_af() { - // No librubberband in this build: mpv rejects the *whole* chain, loudness gain - // included, so put the old value back rather than leave every later set_gain failing. - // (mpv never applied the bad chain, so this restores what is already playing.) - self.af.lock().unwrap().1 = previous; + // No librubberband: mpv rejected the *whole* chain, loudness gain included, so put the + // old fx back rather than leave every later set_gain failing. (mpv never applied the + // bad chain, so this restores what is already playing.) + self.af.lock().1 = previous; let _ = self.apply_af(); - return Err(if wanted == 0 { e } else { Error::NoPitchFilter }); + return Err(if fx.semitones == 0 { e } else { Error::NoPitchFilter }); } Ok(()) } fn apply_af(&self) -> Result<(), Error> { - let (gain_db, semitones) = *self.af.lock().unwrap(); - self.mpv.set_property("af", af_chain(gain_db, semitones).as_str())?; + let (gain_db, fx) = *self.af.lock(); + self.mpv.set_property("af", af_chain(gain_db, &fx).as_str())?; Ok(()) } } -/// The whole `af` chain: loudness gain, then pitch. Empty when neither is in play, so the default -/// path stays exactly the filterless one it was before pitch existed. -fn af_chain(gain_db: Option, semitones: i32) -> String { +/// The whole `af` chain: loudness gain, then the [`AudioFx`] filters in order — pitch +/// (rubberband), reverb (`aecho`), bass shelf (`bass`), stereo width (`stereowiden`). Empty when +/// nothing is in play, so the default path stays exactly the filterless one it was before any of +/// this existed. Each effect is omitted at its neutral value, so a lone gain or a lone reverb is +/// just that one filter. +fn af_chain(gain_db: Option, fx: &AudioFx) -> String { let mut chain = Vec::new(); if let Some(g) = gain_db { chain.push(format!("lavfi=[volume={g}dB]")); } - if semitones != 0 { + if fx.semitones != 0 { // Semitones → frequency multiplier (equal temperament). chain.push(format!( "{}=pitch-scale={}", pitch_filter(), - 2f64.powf(semitones as f64 / 12.0) + 2f64.powf(fx.semitones as f64 / 12.0) + )); + } + if fx.reverb > 0.0 { + // Three taps at 40/80/120 ms; their decays fall 0.4/0.3/0.2 at full depth, scaled down + // toward silence as the depth drops. + chain.push(format!( + "lavfi=[aecho=0.8:0.9:40|80|120:{}|{}|{}]", + 0.4 * fx.reverb, + 0.3 * fx.reverb, + 0.2 * fx.reverb + )); + } + if fx.bass_db != 0.0 { + chain.push(format!("lavfi=[bass=g={}:f=110:w=0.6]", fx.bass_db)); + } + if fx.width > 0.0 { + // Widen the stereo image; drymix falls from 1 toward 0.4 as width rises, mixing in more + // of the widened signal. + chain.push(format!( + "lavfi=[stereowiden=delay=20:feedback=0.3:crossfeed=0.3:drymix={}]", + 1.0 - 0.6 * fx.width )); } chain.join(",") } /// Test seam. Set it to reproduce a libmpv built without librubberband: mpv then rejects the whole -/// `af` chain, loudness gain included, which is the failure [`Player::set_pitch`] rolls back from. +/// `af` chain, loudness gain included, which is the failure [`Player::set_fx`] rolls back from. /// A machine that has the filter can't reach that path any other way. Not compiled into the app. #[cfg(test)] static NO_RUBBERBAND: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -323,6 +406,7 @@ fn event_loop( mut ev: EventContext, tx: tokio::sync::mpsc::UnboundedSender, idle_active: Arc, + stop: Arc, ) { // Playback state is derived from two properties, never polled: mpv answers `mpv_get_property` // synchronously on its core lock, so asking it from the app's async event pump can stall that @@ -335,7 +419,7 @@ fn event_loop( let mut paused = false; let mut idle = true; let mut playing = false; - loop { + while !stop.load(Ordering::Acquire) { match ev.wait_event(1.0) { Some(Ok(event)) => { let out = match event { @@ -414,6 +498,28 @@ fn quoted(arg: &str) -> String { format!("\"{}\"", arg.replace('\\', "\\\\").replace('"', "\\\"")) } +/// Per-file `force-media-title` for `loadfile`'s options argument. Two parsers see it: the +/// command-string parser (libmpv2 joins the args into one line) needs the token quoted, and the +/// comma-separated option list inside needs the value length-prefixed (`%len%...`), which takes +/// any bytes verbatim. A per-file option also leaves the gapless lookahead's title untouched. +fn title_option(title: &str) -> String { + quoted(&format!("force-media-title=%{}%{}", title.len(), title)) +} + +/// The per-file options argument: the media title plus any extra `key=value` pairs (a raw PCM +/// FIFO's demuxer settings), comma-joined inside one quoted token. +fn file_options(title: &str, extra: &[String]) -> String { + if extra.is_empty() { + return title_option(title); + } + let mut list = format!("force-media-title=%{}%{}", title.len(), title); + for opt in extra { + list.push(','); + list.push_str(opt); + } + quoted(&list) +} + /// Slider percent → mpv `volume` value, over a 60 dB range. mpv applies gain = (v/100)³, /// i.e. 60·log10(v/100) dB, so v = 100·10^(−(1−s/100)^1.5) yields −60·(1−s/100)^1.5 dB: /// 50% is −21 dB, 25% is −39 dB, 1% is −59 dB. 0 stays a hard mute. @@ -431,17 +537,46 @@ fn perceptual_to_mpv(percent: i64) -> f64 { #[cfg(test)] mod tests { - use super::{af_chain, perceptual_to_mpv, quoted}; + use super::{af_chain, perceptual_to_mpv, quoted, AudioFx}; #[test] - fn gain_and_pitch_share_one_chain() { - // The bug this exists for: either setter clobbering the other's filter. - assert_eq!(af_chain(None, 0), ""); - assert_eq!(af_chain(Some(-3.5), 0), "lavfi=[volume=-3.5dB]"); - assert_eq!(af_chain(None, 12), "rubberband=pitch-scale=2"); - assert_eq!(af_chain(Some(-6.0), -12), "lavfi=[volume=-6dB],rubberband=pitch-scale=0.5"); - // One semitone up is the twelfth root of two. - assert!(af_chain(None, 1).ends_with("1.0594630943592953")); + fn chain_builds_each_effect() { + // All off: the filterless path is preserved exactly. + assert_eq!(af_chain(None, &AudioFx::default()), ""); + // Gain alone. + assert_eq!(af_chain(Some(-3.5), &AudioFx::default()), "lavfi=[volume=-3.5dB]"); + // Pitch alone: one semitone up is the twelfth root of two, an octave up is ×2. + assert_eq!(af_chain(None, &fx(0.0, 0.0, 0.0, 12)), "rubberband=pitch-scale=2"); + assert!(af_chain(None, &fx(0.0, 0.0, 0.0, 1)).ends_with("1.0594630943592953")); + // Reverb alone: decays 0.4/0.3/0.2 scaled by the depth. + assert_eq!( + af_chain(None, &fx(1.0, 0.0, 0.0, 0)), + "lavfi=[aecho=0.8:0.9:40|80|120:0.4|0.3|0.2]" + ); + assert_eq!( + af_chain(None, &fx(0.5, 0.0, 0.0, 0)), + "lavfi=[aecho=0.8:0.9:40|80|120:0.2|0.15|0.1]" + ); + // Bass alone. + assert_eq!(af_chain(None, &fx(0.0, 8.0, 0.0, 0)), "lavfi=[bass=g=8:f=110:w=0.6]"); + // Width alone: drymix = 1 − 0.6·width. + assert_eq!( + af_chain(None, &fx(0.0, 0.0, 0.5, 0)), + "lavfi=[stereowiden=delay=20:feedback=0.3:crossfeed=0.3:drymix=0.7]" + ); + // Everything at once, in chain order: gain, pitch, reverb, bass, width. + assert_eq!( + af_chain(Some(-6.0), &fx(1.0, -6.0, 0.5, -12)), + "lavfi=[volume=-6dB],rubberband=pitch-scale=0.5,\ + lavfi=[aecho=0.8:0.9:40|80|120:0.4|0.3|0.2],\ + lavfi=[bass=g=-6:f=110:w=0.6],\ + lavfi=[stereowiden=delay=20:feedback=0.3:crossfeed=0.3:drymix=0.7]" + ); + } + + /// A convenience constructor: every field but the four under test at its neutral value. + fn fx(reverb: f64, bass_db: f64, width: f64, semitones: i32) -> AudioFx { + AudioFx { speed: 1.0, semitones, reverb, bass_db, width } } /// Everything above is string-building; this drives a real libmpv and reads `af` back out of @@ -451,37 +586,38 @@ mod tests { /// runs tests in parallel. #[test] fn mpv_keeps_the_gain_through_pitch_changes_and_failures() { - use super::{Error, Player, NO_RUBBERBAND}; + use super::{AudioFx, Error, Player, NO_RUBBERBAND}; use std::sync::atomic::Ordering; let dir = std::env::temp_dir().join("ryotunes-af-test"); std::fs::create_dir_all(&dir).unwrap(); let p = Player::new(dir.to_str().unwrap()).expect("libmpv"); let af = || p.mpv.get_property::("af").unwrap(); + let pitch = |semitones| AudioFx { semitones, ..AudioFx::default() }; // 1. Loudness normalization, then a pitch round trip. The gain has to survive both steps. p.set_gain(Some(-7.7)).unwrap(); assert!(af().contains("volume=-7.7dB"), "gain missing: {}", af()); - p.set_pitch(2).unwrap(); + p.set_fx(&pitch(2)).unwrap(); assert!(af().contains("volume=-7.7dB"), "pitch dropped the gain: {}", af()); assert!(af().contains("rubberband"), "pitch missing: {}", af()); - p.set_pitch(0).unwrap(); + p.set_fx(&pitch(0)).unwrap(); assert!(af().contains("volume=-7.7dB"), "reset dropped the gain: {}", af()); assert!(!af().contains("rubberband"), "pitch 0 left a filter behind: {}", af()); - // 2. Gapless advance: the orchestrator retunes the gain for the next track (state.rs, the - // `lookahead_gain` take). A pitch the user set must not fall out of the chain when it does. - p.set_pitch(-5).unwrap(); + // 2. Gapless advance: the orchestrator retunes the gain for the next track. A pitch the + // user set must not fall out of the chain when it does. + p.set_fx(&pitch(-5)).unwrap(); p.set_gain(Some(-2.5)).unwrap(); assert!(af().contains("volume=-2.5dB"), "retune missed: {}", af()); assert!(af().contains("rubberband"), "retune dropped the pitch: {}", af()); - p.set_pitch(0).unwrap(); + p.set_fx(&pitch(0)).unwrap(); // 3. A libmpv without librubberband. mpv rejects the chain wholesale, so this is also the // case where loudness normalization could silently disappear. let before = af(); NO_RUBBERBAND.store(true, Ordering::Relaxed); - let err = p.set_pitch(3).unwrap_err(); + let err = p.set_fx(&pitch(3)).unwrap_err(); NO_RUBBERBAND.store(false, Ordering::Relaxed); // The user is told, in words. mpv's own answer is `Raw(-9)`, which says nothing. assert!(matches!(err, Error::NoPitchFilter), "rejection must surface: {err}"); @@ -489,12 +625,22 @@ mod tests { // mpv never applied the bad chain, and the rollback re-applied the good one either way. assert_eq!(af(), before, "a rejected pitch changed the live chain"); assert!(af().contains("volume=-2.5dB"), "normalization lost: {}", af()); - // And the rolled-back state is clean: the next per-track retune is gain-only, not a - // permanently poisoned chain that fails from here on. + // And the rolled-back state is clean: the next per-track retune is gain-only. p.set_gain(Some(-4.0)).unwrap(); let after = af(); // mpv hands the chain back in its own escaped form, hence `contains` assert!(after.contains("volume=-4dB"), "retune after a rejection failed: {after}"); assert!(!after.contains("rubberband"), "stored pitch survived the rollback: {after}"); + + // 4. The reverb/bass/width filters reach the live chain and mpv accepts their syntax + // (set_property would error otherwise — this is the only proof aecho/bass/stereowiden + // parse on this build). + p.set_gain(None).unwrap(); + p.set_fx(&AudioFx { speed: 1.0, semitones: 0, reverb: 1.0, bass_db: 8.0, width: 0.8 }) + .unwrap(); + let live = af(); + assert!(live.contains("aecho"), "reverb missing from live chain: {live}"); + assert!(live.contains("bass"), "bass missing from live chain: {live}"); + assert!(live.contains("stereowiden"), "width missing from live chain: {live}"); } #[test] diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 0000000..e019037 --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ryotunes-protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes daemon wire protocol: newline-delimited JSON requests, responses and events." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 0000000..855f008 --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,23 @@ +//! The daemon's socket protocol. One JSON object per line in both directions. +pub mod wire; + +pub use wire::{ErrorBody, Event, Incoming, Outgoing, Request, Response, PROTOCOL_VERSION}; + +use std::path::PathBuf; + +/// `$XDG_RUNTIME_DIR/ryotunes/ryotunesd.sock`, or `/tmp/ryotunes-/ryotunesd.sock` without a +/// runtime dir (a bare TTY login). The directory is created by the daemon with mode 0700. +pub fn socket_path() -> PathBuf { + let dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(|| { + std::env::temp_dir().join(format!("ryotunes-{}", unsafe { libc_uid() })) + }); + dir.join("ryotunes").join("ryotunesd.sock") +} + +// No libc dependency for one call: read the uid from the kernel. +unsafe fn libc_uid() -> u32 { + std::fs::read_to_string("/proc/self/loginuid") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) +} diff --git a/crates/protocol/src/wire.rs b/crates/protocol/src/wire.rs new file mode 100644 index 0000000..8ea1303 --- /dev/null +++ b/crates/protocol/src/wire.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Bumped on any incompatible change to method names, shapes or events. +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Request { + pub id: u64, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Response { + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + pub fn ok(id: u64, result: Value) -> Self { + Response { id, result: Some(result), error: None } + } + pub fn err(id: u64, code: &str, message: impl Into) -> Self { + Response { + id, + result: None, + error: Some(ErrorBody { code: code.into(), message: message.into() }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Event { + pub event: String, + pub data: Value, +} + +/// What a client may send. Only requests today; the enum leaves room for a `cancel`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Incoming { + Request(Request), +} + +/// What the daemon sends. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Outgoing { + Response(Response), + Event(Event), +} + +impl Outgoing { + /// One line, newline included. + pub fn to_line(&self) -> String { + let mut s = serde_json::to_string(self).expect("wire types serialize"); + s.push('\n'); + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips_with_camel_case_params() { + let line = r#"{"id":7,"method":"play","params":{"item":{"videoId":"abc"}}}"#; + let req: Incoming = serde_json::from_str(line).unwrap(); + let Incoming::Request(req) = req; + assert_eq!(req.id, 7); + assert_eq!(req.method, "play"); + assert_eq!(req.params["item"]["videoId"], "abc"); + } + + #[test] + fn params_default_to_null() { + let Incoming::Request(req) = serde_json::from_str(r#"{"id":1,"method":"hello"}"#).unwrap(); + assert!(req.params.is_null()); + } + + #[test] + fn response_omits_the_absent_half() { + assert_eq!( + Outgoing::Response(Response::ok(1, Value::Bool(true))).to_line(), + "{\"id\":1,\"result\":true}\n" + ); + assert_eq!( + Outgoing::Response(Response::err(2, "upload_unavailable", "x")).to_line(), + "{\"id\":2,\"error\":{\"code\":\"upload_unavailable\",\"message\":\"x\"}}\n" + ); + } + + #[test] + fn events_and_responses_are_distinguishable_on_the_wire() { + let ev: Outgoing = + serde_json::from_str(r#"{"event":"position","data":{"position":1.5}}"#).unwrap(); + assert!(matches!(ev, Outgoing::Event(_))); + let resp: Outgoing = serde_json::from_str(r#"{"id":3,"result":null}"#).unwrap(); + assert!(matches!(resp, Outgoing::Response(_))); + } +} diff --git a/crates/ryotunes-cli/Cargo.toml b/crates/ryotunes-cli/Cargo.toml new file mode 100644 index 0000000..5c87452 --- /dev/null +++ b/crates/ryotunes-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ryotunes-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes daemon command-line client: run a method or follow events over the socket." + +[[bin]] +name = "ryotunes-cli" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +ryotunes-protocol = { workspace = true } diff --git a/crates/ryotunes-cli/src/main.rs b/crates/ryotunes-cli/src/main.rs new file mode 100644 index 0000000..aab3da9 --- /dev/null +++ b/crates/ryotunes-cli/src/main.rs @@ -0,0 +1,118 @@ +//! `ryotunes-cli [json-params]`, `ryotunes-cli events [names...]`, or the local, +//! socket-free `ryotunes-cli skin ` (see skin.rs). + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; + +use ryotunes_protocol::{Outgoing, Request}; + +mod skin; + +fn parse(args: &[String]) -> Result<(Option, Request, bool), String> { + let mut socket = None; + let mut rest = args.to_vec(); + if rest.first().map(String::as_str) == Some("--socket") { + rest.remove(0); + socket = Some(rest.first().ok_or("--socket needs a path")?.into()); + rest.remove(0); + } + let method = rest + .first() + .ok_or("usage: ryotunes-cli [--socket PATH] [json] | events [name...]")? + .clone(); + if method == "events" { + let names: Vec = rest[1..].to_vec(); + let params = if names.is_empty() { + serde_json::json!({ "events": ["*"] }) + } else { + serde_json::json!({ "events": names }) + }; + return Ok((socket, Request { id: 1, method: "subscribe".into(), params }, true)); + } + let params = match rest.get(1) { + Some(raw) => serde_json::from_str(raw).map_err(|e| format!("params: {e}"))?, + None => serde_json::Value::Null, + }; + Ok((socket, Request { id: 1, method, params }, false)) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + // `skin` is a local toolbox: it never touches the daemon socket. + if args.first().map(String::as_str) == Some("skin") { + std::process::exit(skin::run(&args[1..])); + } + let (socket, req, follow) = match parse(&args) { + Ok(v) => v, + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + }; + let path = socket.unwrap_or_else(ryotunes_protocol::socket_path); + let mut stream = match UnixStream::connect(&path) { + Ok(s) => s, + Err(e) => { + eprintln!("ryotunesd not reachable at {}: {e}", path.display()); + std::process::exit(3); + } + }; + let mut line = serde_json::to_string(&req).unwrap(); + line.push('\n'); + stream.write_all(line.as_bytes()).unwrap(); + let reader = BufReader::new(stream.try_clone().unwrap()); + for l in reader.lines() { + let l = match l { + Ok(l) => l, + Err(_) => break, + }; + match serde_json::from_str::(&l) { + Ok(Outgoing::Response(r)) => { + if let Some(e) = r.error { + println!("{}", serde_json::to_string(&e).unwrap()); + std::process::exit(1); + } + if !follow { + println!("{}", r.result.unwrap_or(serde_json::Value::Null)); + return; + } + } + Ok(Outgoing::Event(ev)) => { + if follow { + println!("{}", serde_json::to_string(&ev).unwrap()); + } + } + Err(_) => eprintln!("unparsable line: {l}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_method_with_and_without_params() { + let (_, r, follow) = parse(&["seek".into(), r#"{"position":12}"#.into()]).unwrap(); + assert_eq!(r.method, "seek"); + assert_eq!(r.params["position"], 12); + assert!(!follow); + let (_, r, _) = parse(&["hello".into()]).unwrap(); + assert!(r.params.is_null()); + } + + #[test] + fn events_subscribes_and_follows() { + let (_, r, follow) = parse(&["events".into(), "position".into()]).unwrap(); + assert_eq!(r.method, "subscribe"); + assert_eq!(r.params["events"][0], "position"); + assert!(follow); + } + + #[test] + fn socket_override_is_consumed_first() { + let (s, r, _) = parse(&["--socket".into(), "/tmp/x.sock".into(), "hello".into()]).unwrap(); + assert_eq!(s.unwrap(), std::path::PathBuf::from("/tmp/x.sock")); + assert_eq!(r.method, "hello"); + } +} diff --git a/crates/ryotunes-cli/src/skin.rs b/crates/ryotunes-cli/src/skin.rs new file mode 100644 index 0000000..32854a1 --- /dev/null +++ b/crates/ryotunes-cli/src/skin.rs @@ -0,0 +1,807 @@ +//! `ryotunes-cli skin `: a local, socket-free skin toolbox. +//! +//! Parses a skin manifest (docs/SKINS.md / skins/skin.schema.json) into a typed shape, enforces the +//! format-1 contract (id == folder, lowercase kebab; a mode with paper+ink; `#rrggbb` colours; WCAG +//! contrast on the ink pairs; type/shape/motion/decor/accent/wash ranges; fonts present) and prints +//! a swatch table with 24-bit ANSI blocks when stdout is a tty. Unknown keys warn; errors exit 1. +//! None of this needs the daemon. + +use std::collections::BTreeMap; +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +// --- the typed manifest ----------------------------------------------------------------------- + +const COLOR_KEYS: [&str; 8] = + ["paper", "paperLift", "ink", "inkDim", "bone", "inkOnBone", "sun", "alert"]; + +#[derive(Deserialize, Default)] +struct Manifest { + format: Option, + id: Option, + name: Option, + author: Option, + version: Option, + license: Option, + homepage: Option, + description: Option, + #[serde(rename = "default")] + default_mode: Option, + modes: Option>, + #[serde(rename = "type")] + type_: Option, + shape: Option, + motion: Option, + decor: Option, + accent: Option, + wash: Option, + fonts: Option>, + generated: Option, + // Captured only so `"$schema"` (paper/ember/mist reference the schema) is a known key. + #[serde(rename = "$schema")] + #[allow(dead_code)] + schema: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Deserialize, Default)] +struct Mode { + paper: Option, + #[serde(rename = "paperLift")] + paper_lift: Option, + ink: Option, + #[serde(rename = "inkDim")] + ink_dim: Option, + bone: Option, + #[serde(rename = "inkOnBone")] + ink_on_bone: Option, + sun: Option, + alert: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +impl Mode { + fn color(&self, key: &str) -> Option<&str> { + match key { + "paper" => self.paper.as_deref(), + "paperLift" => self.paper_lift.as_deref(), + "ink" => self.ink.as_deref(), + "inkDim" => self.ink_dim.as_deref(), + "bone" => self.bone.as_deref(), + "inkOnBone" => self.ink_on_bone.as_deref(), + "sun" => self.sun.as_deref(), + "alert" => self.alert.as_deref(), + _ => None, + } + } +} + +#[derive(Deserialize, Default)] +struct TypeSpec { + #[allow(dead_code)] + display: Option, + #[allow(dead_code)] + ui: Option, + #[allow(dead_code)] + mono: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Deserialize, Default)] +struct Shape { + radius: Option, + #[serde(rename = "radiusCard")] + radius_card: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Deserialize, Default)] +struct Motion { + snap: Option, + #[serde(rename = "move")] + move_: Option, + swap: Option, + slow: Option, + #[serde(flatten)] + extra: BTreeMap, +} + +// --- colour + contrast ------------------------------------------------------------------------ + +/// Parse exactly `#rrggbb` (case-insensitive) into 8-bit channels; anything else is `None`. +fn parse_hex(s: &str) -> Option<(u8, u8, u8)> { + let s = s.strip_prefix('#')?; + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some((r, g, b)) +} + +/// WCAG 2.x relative luminance of an sRGB colour. +fn luminance((r, g, b): (u8, u8, u8)) -> f64 { + fn lin(c: u8) -> f64 { + let c = c as f64 / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} + +/// WCAG contrast ratio (1..21) between two colours, order-independent. +fn contrast_ratio(a: (u8, u8, u8), b: (u8, u8, u8)) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +/// Lowercase kebab-case: `a-z0-9` groups joined by single hyphens, no leading/trailing/double `-`. +fn is_kebab(s: &str) -> bool { + if s.is_empty() { + return false; + } + let ok_char = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'; + if !s.chars().all(ok_char) { + return false; + } + if s.starts_with('-') || s.ends_with('-') || s.contains("--") { + return false; + } + true +} + +// --- validation ------------------------------------------------------------------------------- + +#[derive(Default)] +struct Report { + errors: Vec, + warnings: Vec, +} +impl Report { + fn err(&mut self, m: String) { + self.errors.push(m); + } + fn warn(&mut self, m: String) { + self.warnings.push(m); + } +} + +fn contrast_checks(name: &str, mode: &Mode, r: &mut Report) { + let g = |k: &str| mode.color(k).and_then(parse_hex); + if let (Some(ink), Some(paper)) = (g("ink"), g("paper")) { + let c = contrast_ratio(ink, paper); + if c < 4.5 { + r.err(format!("{name}: ink/paper contrast {c:.2} < 4.5 (WCAG AA body text)")); + } + } + if let (Some(d), Some(paper)) = (g("inkDim"), g("paper")) { + let c = contrast_ratio(d, paper); + if c < 3.0 { + r.warn(format!("{name}: inkDim/paper contrast {c:.2} < 3.0 (dim text hard to read)")); + } + } + if let (Some(iob), Some(bone)) = (g("inkOnBone"), g("bone")) { + let c = contrast_ratio(iob, bone); + if c < 4.5 { + r.err(format!("{name}: inkOnBone/bone contrast {c:.2} < 4.5 (WCAG AA body text)")); + } + } + if let (Some(sun), Some(paper)) = (g("sun"), g("paper")) { + let c = contrast_ratio(sun, paper); + if c < 3.0 { + r.warn(format!("{name}: sun/paper contrast {c:.2} < 3.0 (accent barely separates)")); + } + } +} + +/// The heart of `skin check`: every rule the format-1 contract enforces. `folder` is the directory +/// name the id must equal; `dir` is where fonts and preview.png live. +fn validate(m: &Manifest, folder: &str, dir: &Path) -> Report { + let mut r = Report::default(); + + match m.format { + Some(1) => {} + Some(other) => r.err(format!("format must be 1, got {other}")), + None => r.err("format is required and must be 1".into()), + } + + match &m.id { + Some(id) => { + if !is_kebab(id) { + r.err(format!( + "id \"{id}\" must be lowercase kebab-case (a-z, 0-9, single hyphens)" + )); + } + if id != folder { + r.err(format!("id \"{id}\" must equal its folder name \"{folder}\"")); + } + } + None => r.err(format!("id is required and must equal the folder name \"{folder}\"")), + } + + if let Some(d) = &m.default_mode { + if d != "dark" && d != "light" { + r.err(format!("default \"{d}\" must be \"dark\" or \"light\"")); + } + } + + match &m.modes { + None => r.err("modes is required: at least one mode carrying paper and ink".into()), + Some(modes) if modes.is_empty() => { + r.err("modes is empty: need at least one mode with paper and ink".into()); + } + Some(modes) => { + let mut any_full = false; + for (name, mode) in modes { + if name != "dark" && name != "light" { + r.warn(format!( + "mode \"{name}\" is neither \"dark\" nor \"light\"; the client ignores it" + )); + } + for key in COLOR_KEYS { + if let Some(hex) = mode.color(key) { + if parse_hex(hex).is_none() { + r.err(format!("{name}.{key} \"{hex}\" is not #rrggbb")); + } + } + } + if mode.paper.is_some() && mode.ink.is_some() { + any_full = true; + } + contrast_checks(name, mode, &mut r); + for k in mode.extra.keys() { + r.warn(format!("unknown key \"modes.{name}.{k}\"")); + } + } + if !any_full { + r.err("no mode carries both paper and ink".into()); + } + } + } + + if let Some(d) = &m.decor { + if d != "rich" && d != "calm" { + r.err(format!("decor \"{d}\" must be \"rich\" or \"calm\"")); + } + } + + if let Some(a) = &m.accent { + let ok = matches!(a.as_str(), "artwork" | "provider" | "sun") || parse_hex(a).is_some(); + if !ok { + r.err(format!("accent \"{a}\" must be \"artwork\", \"provider\", \"sun\", or #rrggbb")); + } + } + + if let Some(w) = m.wash { + if !(0.0..=2.0).contains(&w) { + r.err(format!("wash {w} is out of range 0..2")); + } + } + + if let Some(s) = &m.shape { + for (k, v) in [("radius", s.radius), ("radiusCard", s.radius_card)] { + if let Some(v) = v { + if v < 0.0 { + r.err(format!("shape.{k} {v} must be >= 0")); + } + } + } + for k in s.extra.keys() { + r.warn(format!("unknown key \"shape.{k}\"")); + } + } + + if let Some(mo) = &m.motion { + for (k, v) in [("snap", mo.snap), ("move", mo.move_), ("swap", mo.swap), ("slow", mo.slow)] + { + if let Some(v) = v { + if v < 0.0 { + r.err(format!("motion.{k} {v}ms must be >= 0")); + } + } + } + for k in mo.extra.keys() { + r.warn(format!("unknown key \"motion.{k}\"")); + } + } + + if let Some(t) = &m.type_ { + for k in t.extra.keys() { + r.warn(format!("unknown key \"type.{k}\"")); + } + } + + if let Some(fonts) = &m.fonts { + for f in fonts { + if !dir.join(f).exists() { + r.err(format!("font \"{f}\" not found under {}", dir.display())); + } + } + } + + if !dir.join("preview.png").exists() { + r.warn("preview.png is missing (recommended: 800x500, scripts/dev/skin-preview.sh)".into()); + } + + for k in m.extra.keys() { + r.warn(format!("unknown key \"{k}\"")); + } + + r +} + +// --- rendering -------------------------------------------------------------------------------- + +fn block(rgb: (u8, u8, u8)) -> String { + format!("\x1b[48;2;{};{};{}m \x1b[0m", rgb.0, rgb.1, rgb.2) +} + +/// Ordered (dark, light, then any extra) columns of the modes a manifest declares. +fn mode_columns(m: &Manifest) -> Vec<(String, &Mode)> { + let mut cols = Vec::new(); + let modes = match &m.modes { + Some(x) => x, + None => return cols, + }; + for name in ["dark", "light"] { + if let Some(md) = modes.get(name) { + cols.push((name.to_string(), md)); + } + } + for (name, md) in modes { + if name != "dark" && name != "light" { + cols.push((name.clone(), md)); + } + } + cols +} + +fn print_swatches(m: &Manifest, tty: bool) { + let cols = mode_columns(m); + if cols.is_empty() { + return; + } + let mut hdr = format!(" {:<11}", "colour"); + for (name, _) in &cols { + hdr.push_str(&format!(" {:<9}", name)); + } + println!("{}", hdr.trim_end()); + for key in COLOR_KEYS { + let mut line = format!(" {key:<11}"); + for (_, md) in &cols { + match md.color(key) { + Some(hex) => { + let pre = match parse_hex(hex) { + Some(rgb) if tty => format!("{} ", block(rgb)), + _ => " ".to_string(), + }; + line.push_str(&pre); + line.push_str(&format!("{hex:<9}")); + } + None => line.push_str(&format!(" {:<9}", "—")), + } + } + println!("{}", line.trim_end()); + } +} + +// --- skin directory resolution (list / show) -------------------------------------------------- + +#[derive(Clone, Copy)] +enum Origin { + Dev, + User, + Store, + Shipped, +} +impl Origin { + fn label(self) -> &'static str { + match self { + Origin::Dev => "dev", + Origin::User => "user", + Origin::Store => "store", + Origin::Shipped => "shipped", + } + } +} + +fn config_home() -> String { + std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("{}/.config", std::env::var("HOME").unwrap_or_default())) +} + +fn data_home() -> String { + std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())) +} + +/// The same search order the client uses (Skin.qml): dev dirs, the user dir, the store's +/// library (`~/.local/share/ryoku/ryotunes-skins`, what RyoStore installs into), then the shipped +/// dir (`/usr/share/ryotunes/skins` when installed, `./skins` in a checkout). First hit per id wins. +fn skin_dirs() -> Vec<(Origin, PathBuf)> { + let mut dirs = Vec::new(); + if let Ok(v) = std::env::var("RYOTUNES_SKIN_DIRS") { + for d in v.split(':').filter(|s| !s.is_empty()) { + dirs.push((Origin::Dev, PathBuf::from(d))); + } + } + dirs.push((Origin::User, PathBuf::from(format!("{}/ryotunes/skins", config_home())))); + dirs.push((Origin::Store, PathBuf::from(format!("{}/ryoku/ryotunes-skins", data_home())))); + let ship = PathBuf::from("/usr/share/ryotunes/skins"); + if ship.is_dir() { + dirs.push((Origin::Shipped, ship)); + } + let local = PathBuf::from("skins"); + if local.is_dir() { + dirs.push((Origin::Shipped, local)); + } + dirs +} + +struct Cat { + id: String, + name: String, + version: String, + source: &'static str, + dir: PathBuf, + manifest: Manifest, +} + +fn catalogue() -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for (origin, dir) in skin_dirs() { + let rd = match fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + let mut subs: Vec = + rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect(); + subs.sort(); + for sub in subs { + let json = sub.join("skin.json"); + if !json.is_file() { + continue; + } + let text = match fs::read_to_string(&json) { + Ok(t) => t, + Err(_) => continue, + }; + let m: Manifest = match serde_json::from_str(&text) { + Ok(m) => m, + Err(_) => continue, + }; + let folder = + sub.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let id = m.id.clone().unwrap_or(folder); + if !seen.insert(id.clone()) { + continue; + } + out.push(Cat { + name: m.name.clone().unwrap_or_else(|| id.clone()), + version: m.version.clone().unwrap_or_default(), + source: origin.label(), + dir: sub, + id, + manifest: m, + }); + } + } + out +} + +// --- commands --------------------------------------------------------------------------------- + +/// Resolve a `check` argument (a skin folder or a skin.json path) to (manifest, folder-name, dir). +fn load(path: &Path) -> Result<(Manifest, String, PathBuf), String> { + let is_json_file = + path.is_file() && path.file_name().map(|f| f == "skin.json").unwrap_or(false); + let (json, dir) = if is_json_file { + let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); + (path.to_path_buf(), dir) + } else { + (path.join("skin.json"), path.to_path_buf()) + }; + let folder = dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| dir.display().to_string()); + let text = fs::read_to_string(&json).map_err(|e| format!("{}: {e}", json.display()))?; + let m: Manifest = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", json.display()))?; + Ok((m, folder, dir)) +} + +fn cmd_check(paths: &[String]) -> i32 { + if paths.is_empty() { + eprintln!("usage: ryotunes-cli skin check [more...]"); + return 2; + } + let tty = std::io::stdout().is_terminal(); + let mut had_error = false; + for (i, p) in paths.iter().enumerate() { + if i > 0 { + println!(); + } + match load(Path::new(p)) { + Err(e) => { + println!("{p}: {e}"); + had_error = true; + } + Ok((m, folder, dir)) => { + let title = m.name.clone().unwrap_or_else(|| folder.clone()); + let ver = m.version.clone().unwrap_or_default(); + let tag = if ver.is_empty() { String::new() } else { format!(" {ver}") }; + println!("{} — {title}{tag}", dir.display()); + print_swatches(&m, tty); + let report = validate(&m, &folder, &dir); + for w in &report.warnings { + println!(" warn: {w}"); + } + for e in &report.errors { + println!(" error: {e}"); + } + let (ne, nw) = (report.errors.len(), report.warnings.len()); + if ne == 0 { + println!(" PASS ({nw} warning{})", plural(nw)); + } else { + println!(" FAIL ({ne} error{}, {nw} warning{})", plural(ne), plural(nw)); + had_error = true; + } + } + } + } + if had_error { + 1 + } else { + 0 + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +fn cmd_list() -> i32 { + let cat = catalogue(); + if cat.is_empty() { + println!("no skins found (looked in RYOTUNES_SKIN_DIRS, ~/.config/ryotunes/skins, ./skins, /usr/share/ryotunes/skins)"); + return 0; + } + println!("{:<18}{:<10}{:<10}{}", "id", "source", "version", "name"); + for c in &cat { + println!("{:<18}{:<10}{:<10}{}", c.id, c.source, c.version, c.name); + } + 0 +} + +fn cmd_show(id: Option<&str>) -> i32 { + let id = match id { + Some(i) => i, + None => { + eprintln!("usage: ryotunes-cli skin show "); + return 2; + } + }; + let cat = catalogue(); + let c = match cat.iter().find(|c| c.id == id) { + Some(c) => c, + None => { + eprintln!("skin \"{id}\" is not installed (try: ryotunes-cli skin list)"); + return 1; + } + }; + let tty = std::io::stdout().is_terminal(); + println!("{} ({}, {})", c.id, c.source, c.dir.display()); + println!(" name {}", c.name); + if let Some(a) = c.manifest.author.as_deref().filter(|s| !s.is_empty()) { + println!(" author {a}"); + } + if !c.version.is_empty() { + println!(" version {}", c.version); + } + if let Some(l) = c.manifest.license.as_deref().filter(|s| !s.is_empty()) { + println!(" license {l}"); + } + if let Some(h) = c.manifest.homepage.as_deref().filter(|s| !s.is_empty()) { + println!(" homepage {h}"); + } + println!(" default {}", c.manifest.default_mode.clone().unwrap_or_else(|| "dark".into())); + if let Some(d) = c.manifest.description.as_deref().filter(|s| !s.is_empty()) { + println!(" {d}"); + } + if let Some(g) = c.manifest.generated.as_deref().filter(|s| !s.is_empty()) { + println!(" generated by {g} — fork it from Settings to edit"); + } + print_swatches(&c.manifest, tty); + let report = validate(&c.manifest, &c.id, &c.dir); + for w in &report.warnings { + println!(" warn: {w}"); + } + for e in &report.errors { + println!(" error: {e}"); + } + 0 +} + +/// `skin use `: select a skin by merging `"skin"` into the client's prefs file +/// (`~/.config/ryotunes/client.json`). The running client watches that file and repaints; no +/// daemon involved. Every other key in the file is kept. +fn cmd_use(id: Option<&str>) -> i32 { + let Some(id) = id else { + eprintln!("usage: ryotunes-cli skin use "); + return 2; + }; + if id != "system" { + if !is_kebab(id) { + eprintln!("\"{id}\" is not a skin id (lowercase kebab-case)"); + return 2; + } + if !catalogue().iter().any(|c| c.id == id) { + eprintln!("skin \"{id}\" is not installed (see `ryotunes-cli skin list`)"); + return 1; + } + } + let path = PathBuf::from(format!("{}/ryotunes/client.json", config_home())); + let mut prefs: serde_json::Map = match fs::read_to_string(&path) { + Ok(t) => serde_json::from_str::(&t) + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(), + Err(_) => Default::default(), + }; + prefs.insert("skin".into(), serde_json::Value::String(id.to_owned())); + let body = match serde_json::to_string_pretty(&serde_json::Value::Object(prefs)) { + Ok(b) => b + "\n", + Err(e) => { + eprintln!("cannot encode {}: {e}", path.display()); + return 1; + } + }; + if let Some(dir) = path.parent() { + let _ = fs::create_dir_all(dir); + } + // Atomic: the client re-reads on change and must never see a half-written file. + let tmp = path.with_extension("json.tmp"); + if let Err(e) = fs::write(&tmp, body).and_then(|_| fs::rename(&tmp, &path)) { + eprintln!("cannot write {}: {e}", path.display()); + return 1; + } + println!("skin: {id}"); + 0 +} + +pub fn run(args: &[String]) -> i32 { + const USAGE: &str = "usage: ryotunes-cli skin "; + match args.first().map(String::as_str) { + Some("check") => cmd_check(&args[1..]), + Some("list") => cmd_list(), + Some("show") => cmd_show(args.get(1).map(String::as_str)), + Some("use") => cmd_use(args.get(1).map(String::as_str)), + Some(other) => { + eprintln!("unknown skin command \"{other}\""); + eprintln!("{USAGE}"); + 2 + } + None => { + eprintln!("{USAGE}"); + 2 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contrast_known_pairs() { + // Black on white is the WCAG maximum, 21:1; a colour on itself is 1:1. + assert!((contrast_ratio((255, 255, 255), (0, 0, 0)) - 21.0).abs() < 0.01); + assert!((contrast_ratio((10, 20, 30), (10, 20, 30)) - 1.0).abs() < 1e-9); + // #777777 on white is a well-known ~4.48 (just under AA body text). + let c = contrast_ratio((0x77, 0x77, 0x77), (255, 255, 255)); + assert!(c > 4.4 && c < 4.6, "expected ~4.48, got {c}"); + assert!( + contrast_ratio((0, 0, 0), (255, 255, 255)) - contrast_ratio((255, 255, 255), (0, 0, 0)) + == 0.0 + ); + } + + #[test] + fn kebab_rule() { + for good in ["paper", "my-cool-skin", "skin2", "a1-b2-c3"] { + assert!(is_kebab(good), "{good} should be kebab"); + } + for bad in ["Paper", "-x", "x-", "a--b", "a_b", "", "café", "UPPER"] { + assert!(!is_kebab(bad), "{bad} should not be kebab"); + } + } + + #[test] + fn parse_hex_is_strict() { + assert_eq!(parse_hex("#050505"), Some((5, 5, 5))); + assert_eq!(parse_hex("#FFFFFF"), Some((255, 255, 255))); + assert!(parse_hex("#fff").is_none()); // shorthand not allowed + assert!(parse_hex("050505").is_none()); // missing # + assert!(parse_hex("#gggggg").is_none()); // non-hex + assert!(parse_hex("#0505055").is_none()); // too long + } + + #[test] + fn bad_hex_is_an_error() { + let json = r##"{ + "format": 1, "id": "demo", + "modes": { "dark": { "paper": "#050505", "ink": "#gggggg" } } + }"##; + let m: Manifest = serde_json::from_str(json).unwrap(); + let report = validate(&m, "demo", Path::new("/nonexistent-skin-dir")); + assert!( + report.errors.iter().any(|e| e.contains("dark.ink") && e.contains("#rrggbb")), + "bad ink hex should error, got {:?}", + report.errors + ); + } + + #[test] + fn low_contrast_ink_fails() { + // ink barely different from paper: below 4.5 -> error. + let json = r##"{ + "format": 1, "id": "demo", + "modes": { "dark": { "paper": "#202020", "ink": "#2a2a2a" } } + }"##; + let m: Manifest = serde_json::from_str(json).unwrap(); + let report = validate(&m, "demo", Path::new("/nonexistent-skin-dir")); + assert!( + report.errors.iter().any(|e| e.contains("ink/paper contrast")), + "low ink/paper contrast should error, got {:?}", + report.errors + ); + } + + #[test] + fn id_must_match_folder_and_format_must_be_one() { + let json = r##"{ + "format": 2, "id": "Other", + "modes": { "dark": { "paper": "#050505", "ink": "#d7cfc6" } } + }"##; + let m: Manifest = serde_json::from_str(json).unwrap(); + let report = validate(&m, "demo", Path::new("/nonexistent-skin-dir")); + assert!(report.errors.iter().any(|e| e.contains("format must be 1"))); + assert!(report.errors.iter().any(|e| e.contains("kebab"))); + assert!(report.errors.iter().any(|e| e.contains("folder name"))); + } + + #[test] + fn unknown_key_warns_but_does_not_fail() { + let json = r##"{ + "format": 1, "id": "demo", "wobble": 3, + "modes": { "dark": { "paper": "#050505", "ink": "#d7cfc6", "bogus": "x" } } + }"##; + let m: Manifest = serde_json::from_str(json).unwrap(); + let report = validate(&m, "demo", Path::new("/nonexistent-skin-dir")); + assert!(report.warnings.iter().any(|w| w.contains("\"wobble\""))); + assert!(report.warnings.iter().any(|w| w.contains("modes.dark.bogus"))); + } +} diff --git a/crates/ryotunesd/Cargo.toml b/crates/ryotunesd/Cargo.toml new file mode 100644 index 0000000..688acc3 --- /dev/null +++ b/crates/ryotunesd/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "ryotunesd" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes playback daemon" + +[[bin]] +name = "ryotunesd" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "signal", "fs", "process"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } +anyhow = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +ryotunes-core = { workspace = true } +ryotunes-soundcloud = { path = "../soundcloud" } +ryotunes-protocol = { workspace = true } +innertube = { path = "../innertube" } +player = { path = "../player" } +libc = "0.2" +parking_lot = "0.12" +gtk = "0.18" +glib = "0.18" +webkit2gtk = { version = "2.0", features = ["v2_40"] } +urlencoding = "2" +# webkit2gtk's `evaluate_javascript` callback hands back a `javascriptcore::Value`; the crate does +# NOT re-export javascriptcore, so `ValueExt::to_json` needs it as a direct dependency (in the lock +# already via webkit2gtk, package `javascriptcore-rs`, so `--locked` is unaffected). +javascriptcore = { package = "javascriptcore-rs", version = "1.1" } +# `session::allowed_login_navigation` takes a `&url::Url`; the GTK policy hook only gives us the URI +# string, so we parse it (Tauri handed a `url::Url` straight to the callback). +url = "2" +# The sign-in window's tests build a fake JWT to feed the cookie harvester (same base64 the +# lock already carries via reqwest). +base64 = "0.22" +# System tray via StatusNotifierItem. `ksni` speaks the protocol directly (left-click `Activate` +# works, unlike libappindicator), the same reason `src-tauri/src/tray.rs` uses it on Linux. +ksni = "0.3" +# Browser-connected SoundCloud sign-in (`browser_auth`): read the session cookies out of the +# user's own browser profile databases (Gecko plaintext, Chromium v10 AES-CBC) instead of an +# embedded webview, so captchas and IdP popups work in the browser they were built for. +rusqlite = { version = "0.32", features = ["bundled"] } +tempfile = "3" +aes = "0.8" +cbc = { version = "0.1", features = ["std", "block-padding"] } +pbkdf2 = "0.12" +sha1 = "0.10" +sha2 = "0.10" +# Download enrichment (`download_media`) embeds cover art + lyrics into the finished audio and reads +# its real duration. Same version core pins so the workspace resolves one `lofty` (in the lock +# already via core, so `--locked` is unaffected). +lofty = "0.22.2" diff --git a/crates/ryotunesd/src/app.rs b/crates/ryotunesd/src/app.rs new file mode 100644 index 0000000..3f5a350 --- /dev/null +++ b/crates/ryotunesd/src/app.rs @@ -0,0 +1,462 @@ +//! Build the core exactly as `src-tauri/src/lib.rs` `setup` does, then pump mpv/media/Listen +//! Together into it. The host seams (`JsBridge`, `LoginFlow`, `EventSink`) are the GTK/socket ones; +//! everything else is the same construction the Tauri app runs, so the daemon opens the app's +//! existing database, cookies and caches. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use innertube::{Clients, InnerTube, Locale, Session}; +use player::{Player, PlayerEvent}; +use ryotunes_core::cipher::{CipherDeobfuscator, PlayerConfigStore}; +use ryotunes_core::db::Db; +use ryotunes_core::host::{EventSink, JsBridge, LoginFlow, Paths}; +use ryotunes_core::listentogether::{LtSession, SyncCommand}; +use ryotunes_core::media::{MediaControlEvent, MediaPosition, SeekDirection}; +use ryotunes_core::orchestrator::Orchestrator; +use ryotunes_core::potoken::PoTokenGenerator; +use ryotunes_core::state::{self, AppState}; +use ryotunes_core::{discord, lastfm, media}; +use tokio::runtime::Handle; +use tokio::sync::mpsc::UnboundedReceiver; + +use crate::lifecycle::Lifecycle; +use crate::methods::normalize_proxy_setting; +use crate::sink::SocketSink; + +/// The XDG directories Tauri resolves on Linux for `dev.ryoku.ryotunes`, so the daemon shares the +/// app's data (`$XDG_DATA_HOME/dev.ryoku.ryotunes`) and audio cache +/// (`$XDG_CACHE_HOME/dev.ryoku.ryotunes/audio`). A relative/unset `XDG_*` is ignored per the spec. +pub fn paths() -> Paths { + const APP_ID: &str = "dev.ryoku.ryotunes"; + Paths { + data_dir: xdg_dir("XDG_DATA_HOME", ".local/share").join(APP_ID), + cache_dir: xdg_dir("XDG_CACHE_HOME", ".cache").join(APP_ID).join("audio"), + } +} + +fn xdg_dir(var: &str, fallback: &str) -> PathBuf { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home_dir().join(fallback)) +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")) +} + +/// Construct the whole core: db + transport + player + orchestrator + integrations + `AppState`, +/// then wire the post-construction background tasks Tauri's `setup` spawns (queue restore, first-run +/// visitorData bootstrap, cipher prewarm, cipher/PoToken idle teardown). Returns the receivers the +/// caller hands to [`spawn_pumps`]. This is a verbatim port of `src-tauri/src/lib.rs` lines 169-334 +/// with the Tauri host seams replaced by the daemon's. +pub fn build( + paths: Paths, + sink: Arc, + js: Arc, + login: Arc, + rt: &Handle, +) -> anyhow::Result<( + Arc, + UnboundedReceiver, + UnboundedReceiver, + UnboundedReceiver, +)> { + let data_dir = paths.data_dir.clone(); + let cache_dir = paths.cache_dir.clone(); + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&cache_dir)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let private_dir = std::fs::Permissions::from_mode(0o700); + let _ = std::fs::set_permissions(&data_dir, private_dir.clone()); + if let Some(cache_root) = cache_dir.parent() { + let _ = std::fs::set_permissions(cache_root, private_dir.clone()); + } + let _ = std::fs::set_permissions(&cache_dir, private_dir); + } + + // Same database name and legacy-name migration as the Tauri app: adopt the sole sibling + // `.sqlite` when the new file is absent, so a build switch never abandons state. + let db_path = data_dir.join("ryotunes.sqlite"); + if !db_path.exists() { + if let Ok(entries) = std::fs::read_dir(&data_dir) { + let mut candidates = entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| { + p.extension().and_then(|x| x.to_str()) == Some("sqlite") && p != &db_path + }); + if let Some(previous) = candidates.next() { + if candidates.next().is_none() && std::fs::rename(&previous, &db_path).is_err() { + let _ = std::fs::copy(&previous, &db_path); + } + } + } + } + + let db = Arc::new(Db::open(&db_path)?); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o600)); + } + + // Session bootstrap: persisted proxy + login session (cookie/dataSyncId/visitorData). A missing + // visitorData is fetched anonymously in the background after construction. + let proxy = db.get_setting("proxy").and_then(|raw| match normalize_proxy_setting(&raw) { + Ok(value) if !value.is_empty() => Some(value), + Ok(_) => None, + Err(error) => { + tracing::warn!(%error, "discarding invalid persisted proxy setting"); + db.delete_setting("proxy"); + None + } + }); + let cookie = db.get_setting("session_cookie").filter(|s| !s.is_empty()); + let data_sync_id = state::persisted_data_sync_id(&db); + let visitor_data = db.get_setting("visitor_data").filter(|s| !s.is_empty()); + let needs_visitor_bootstrap = visitor_data.is_none(); + if cookie.is_some() { + tracing::info!("loaded persisted login session"); + } + + let session = Session { locale: Locale::default(), visitor_data, data_sync_id, cookie }; + let it = match InnerTube::new(session.clone(), proxy.as_deref()) { + Ok(it) => it, + Err(error) => { + tracing::warn!(%error, "stored proxy is invalid — starting without it"); + db.delete_setting("proxy"); + InnerTube::new(session, None).expect("build InnerTube without proxy") + } + }; + let clients = Clients::bundled(); + + let mut player = Player::new(cache_dir.to_str().unwrap()).expect("init libmpv"); + let _ = player.set_volume(state::saved_volume(&db)); + let events = player.take_events().expect("player events"); + + // Cipher and PoToken helpers run in the hidden GTK webviews behind the orchestrator. + let config = Arc::new(PlayerConfigStore::new(&data_dir)); + let cipher = Arc::new(CipherDeobfuscator::new(js.clone(), &data_dir, config)); + let potoken = Arc::new(PoTokenGenerator::new(js.clone(), db.clone())); + let orchestrator = + Arc::new(Orchestrator::new(it.clone(), clients.clone(), cipher.clone(), potoken.clone())); + + let sink_dyn: Arc = sink.clone(); + + // OS media controls (MPRIS). Presses arrive over `media_rx`; the pump drains them into AppState. + let (media_tx, media_rx) = tokio::sync::mpsc::unbounded_channel(); + let media = media::spawn(sink_dyn.clone(), media_tx); + + let discord = discord::spawn( + db.get_setting("discord_rpc").as_deref() == Some("true"), + db.get_setting("discord_presence_name") + .unwrap_or_else(|| discord::DEFAULT_PRESENCE_NAME.into()), + ); + + let lastfm = lastfm::spawn(rt, db.get_setting("lastfm_session_key").filter(|s| !s.is_empty())); + + let lt_url = db.get_setting("lt_server_url").filter(|u| !u.is_empty()).unwrap_or_default(); + let (lt, lt_sync_rx) = LtSession::new(sink_dyn.clone(), lt_url); + + let app_state = Arc::new(AppState::new( + it, + clients, + player, + db, + sink_dyn, + login, + paths, + orchestrator, + lt, + media, + discord, + lastfm, + )); + + // Restore the last session's queue (paused, not autoplaying). + { + let st = app_state.clone(); + rt.spawn(async move { + st.restore_queue().await; + }); + } + + // First-run visitorData bootstrap, off the startup path. A missing visitorData makes every + // anonymous fallback /player request hit YouTube's "confirm you're not a bot" gate, so one + // failed fetch (offline at boot, consent interstitial, transient 5xx) used to leave the daemon + // unable to play *any* track until the next restart. Retry with backoff until it lands. + if needs_visitor_bootstrap { + let st = app_state.clone(); + rt.spawn(async move { + const DELAYS: [Duration; 5] = [ + Duration::from_secs(5), + Duration::from_secs(20), + Duration::from_secs(60), + Duration::from_secs(180), + Duration::from_secs(600), + ]; + for (attempt, delay) in DELAYS.iter().enumerate() { + tokio::time::sleep(*delay).await; + match st.it.fetch_visitor_data().await { + Ok(vd) => { + st.it.set_visitor_data(Some(vd.clone())); + st.db.set_setting("visitor_data", &vd); + tracing::info!("visitorData bootstrapped (background)"); + return; + } + Err(e) => tracing::warn!( + attempt = attempt + 1, + error = %e, + "visitorData bootstrap failed; will retry" + ), + } + } + tracing::warn!("visitorData bootstrap gave up; playback will retry on demand"); + }); + } + + // Fetch player.js off the first-play path once the daemon has settled. + { + let cipher = cipher.clone(); + let st = app_state.clone(); + rt.spawn(async move { + tokio::time::sleep(Duration::from_secs(20)).await; + if !st.low_resource_mode() { + cipher.prewarm().await; + } + }); + } + + // Keep the hidden webviews resident while media is loaded; release them after a long idle. + { + let cipher = cipher.clone(); + let potoken = potoken.clone(); + let st = app_state.clone(); + rt.spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + if st.player.has_loaded_media() { + continue; + } + cipher.teardown_if_idle(Duration::from_secs(300)).await; + potoken.teardown_if_idle(Duration::from_secs(300)).await; + } + }); + } + + Ok((app_state, events, media_rx, lt_sync_rx)) +} + +/// Spawn the three drains that feed `AppState`: the mpv event pump, the OS media-control drain, and +/// the Listen Together sync bridge. Ports `src-tauri/src/lib.rs` `spawn_event_pump` and the two +/// `setup` drains, with `has_ui()` replaced by "any connection subscribed" and the Tauri-only tray +/// / idle-exit calls routed into Task 5's [`Lifecycle`] and [`crate::tray`] instead. +pub fn spawn_pumps( + state: Arc, + events: UnboundedReceiver, + mut media_rx: UnboundedReceiver, + mut lt_rx: UnboundedReceiver, + sink: Arc, + lifecycle: Arc, +) { + spawn_event_pump(state.clone(), events, sink, lifecycle.clone()); + + { + let st = state.clone(); + let lifecycle = lifecycle.clone(); + tokio::spawn(async move { + while let Some(ev) = media_rx.recv().await { + handle_media_event(&st, ev, &lifecycle).await; + } + }); + } + { + let st = state; + tokio::spawn(async move { + while let Some(cmd) = lt_rx.recv().await { + st.apply_sync(cmd).await; + } + }); + } +} + +fn spawn_event_pump( + state: Arc, + mut events: UnboundedReceiver, + sink: Arc, + lifecycle: Arc, +) { + tokio::spawn(async move { + let mut throttle = PositionThrottle::new(); + while let Some(ev) = events.recv().await { + // A subscribed connection is the daemon's equivalent of a visible UI: with none, the + // pump drops to ~1 Hz and emits no socket event, only the backend bookkeeping. + let ui = sink.subscriber_count() > 0; + match ev { + PlayerEvent::Position(p) => { + state.note_position_sample(p); + let cadence = if ui { + if state.low_resource_mode() { + Duration::from_millis(500) + } else { + Duration::from_millis(250) + } + } else { + Duration::from_secs(1) + }; + if throttle.should_emit(p, Instant::now(), cadence) { + if ui { + state.emit("position", serde_json::json!({ "position": p })); + } + state.on_position(p).await; + } + } + PlayerEvent::Duration(d) => { + if ui { + state.emit("duration", serde_json::json!({ "duration": d })); + } + state.on_duration(d).await; + } + PlayerEvent::Playing(playing) => { + if ui { + state.emit("playback-state", if playing { "playing" } else { "paused" }); + } + if !playing { + state.flush_position(); + if ui { + state.emit( + "position", + serde_json::json!({ "position": state.current_position() }), + ); + } + } + state.media_set_playing(playing); + state.lt_on_play_state(playing).await; + // Task 5: keep the tray label and idle-exit deadline in step with playback, + // where the Tauri host called `tray::set_playing` / `schedule_idle_exit`. + crate::tray::set_playing(playing); + lifecycle.playing_changed(playing); + } + PlayerEvent::TrackEnded => { + state.on_track_ended().await; + } + PlayerEvent::TrackFailed(msg) => { + tracing::warn!(error = %msg, "track failed"); + if !state.on_track_failed().await && ui { + state.emit("playback-error", serde_json::json!({ "message": msg })); + } + } + PlayerEvent::Error(msg) => { + tracing::error!(error = %msg, "player error"); + if ui { + state.emit("playback-error", serde_json::json!({ "message": msg })); + } + } + } + } + }); +} + +/// Route an OS media-control press into the same `AppState` methods the socket commands use. Ports +/// `src-tauri/src/lib.rs` `handle_media_event`; the Tauri Stop-arm idle-exit scheduling is now the +/// [`Lifecycle`] call below. +async fn handle_media_event( + state: &Arc, + event: MediaControlEvent, + lifecycle: &Arc, +) { + match event { + MediaControlEvent::Play => state.resume().await, + MediaControlEvent::Toggle => state.resume_or_toggle().await, + MediaControlEvent::Pause => { + let _ = state.player.pause(); + } + MediaControlEvent::Stop => { + let _ = state.player.stop(); + state.media_set_playing(false); + lifecycle.playing_changed(false); + } + MediaControlEvent::Next => state.next_in_queue().await, + MediaControlEvent::Previous => state.prev_in_queue().await, + MediaControlEvent::SetPosition(MediaPosition(pos)) => { + let _ = state.player.seek(pos.as_secs_f64()); + } + MediaControlEvent::SeekBy(dir, by) => { + let delta = if matches!(dir, SeekDirection::Forward) { + by.as_secs_f64() + } else { + -by.as_secs_f64() + }; + let _ = state.player.seek((state.current_position() + delta).max(0.0)); + } + MediaControlEvent::Seek(dir) => { + let delta = if matches!(dir, SeekDirection::Forward) { 10.0 } else { -10.0 }; + let _ = state.player.seek((state.current_position() + delta).max(0.0)); + } + _ => {} + } +} + +/// Decide whether a position sample is worth emitting at the current cadence. Discontinuities +/// (seek / track change) pass immediately; steady samples are batched. Ported verbatim from the +/// Tauri host so the two share one policy. +struct PositionThrottle { + last_emit: Instant, + last_pos: f64, +} + +impl PositionThrottle { + fn new() -> Self { + Self { last_emit: Instant::now() - Duration::from_secs(1), last_pos: f64::NAN } + } + fn should_emit(&mut self, pos: f64, now: Instant, cadence: Duration) -> bool { + let dt = now.duration_since(self.last_emit); + let jumped = + self.last_pos.is_nan() || (pos - self.last_pos).abs() > dt.as_secs_f64() + 0.75; + if jumped || dt >= cadence { + self.last_emit = now; + self.last_pos = pos; + return true; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::PositionThrottle; + use std::time::{Duration, Instant}; + + #[test] + fn steady_playback_throttles_to_250ms() { + let mut t = PositionThrottle::new(); + let base = Instant::now(); + assert!(t.should_emit(0.0, base, Duration::from_millis(250))); + assert!(!t.should_emit(0.1, base + Duration::from_millis(100), Duration::from_millis(250))); + assert!(!t.should_emit(0.2, base + Duration::from_millis(200), Duration::from_millis(250))); + assert!(t.should_emit(0.25, base + Duration::from_millis(250), Duration::from_millis(250))); + } + + #[test] + fn discontinuities_emit_immediately() { + let mut t = PositionThrottle::new(); + let base = Instant::now(); + assert!(t.should_emit(10.0, base, Duration::from_millis(250))); + // Forward jump (media-key seek) despite a short dt. + assert!(t.should_emit(40.0, base + Duration::from_millis(50), Duration::from_millis(250))); + // Backward jump too. + assert!(t.should_emit(10.0, base + Duration::from_millis(80), Duration::from_millis(250))); + } + + #[test] + fn background_cadence_is_one_second() { + let mut t = PositionThrottle::new(); + let base = Instant::now(); + assert!(t.should_emit(0.0, base, Duration::from_secs(1))); + assert!(!t.should_emit(0.5, base + Duration::from_millis(500), Duration::from_secs(1))); + assert!(t.should_emit(1.0, base + Duration::from_secs(1), Duration::from_secs(1))); + } +} diff --git a/crates/ryotunesd/src/browser_auth.rs b/crates/ryotunesd/src/browser_auth.rs new file mode 100644 index 0000000..3814f49 --- /dev/null +++ b/crates/ryotunesd/src/browser_auth.rs @@ -0,0 +1,552 @@ +//! SoundCloud sign-in through the browser the user already uses, instead of a Ryotunes-owned +//! webview. The embedded WebKit window could not complete real sign-ins: hCaptcha challenges +//! and the Google/Facebook/Apple popup flows need a genuine browser (their own engine, their +//! own profile, working popups), and users rightly refuse to type passwords into an app-owned +//! window. So `soundcloud_sign_in` now opens soundcloud.com in the system default browser and +//! imports the session from that browser's own cookie store once the user has signed in there. +//! +//! What gets imported is exactly what the old window harvested — the `oauth_token`, +//! `oauth_refresh_token` and `connect_session` triple scoped to soundcloud.com — read directly +//! from the cookie databases of the Gecko and Chromium families (Firefox, Zen, LibreWolf, …, +//! Chromium, Chrome, Brave, Vivaldi, Edge), including Flatpak and snap layouts. Only rows whose +//! host matches soundcloud.com are ever selected; no other cookie is read, and values are never +//! logged. A running browser keeps its database locked, so each store is copied (with its WAL) +//! to a private temp dir and read from the copy. +//! +//! The user may already be signed in — the first pass then completes the sign-in immediately. +//! Otherwise the caller polls until a valid triple appears or the window times out. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags}; +use ryotunes_soundcloud::SoundcloudAuth; + +/// Where the user is sent: the site's own sign-in page (account form + IdP buttons), which in a +/// real browser has working captchas and popups. +pub const SIGN_IN_URL: &str = "https://soundcloud.com/signin"; + +/// The three cookies that make a SoundCloud web session. +const COOKIE_NAMES: [&str; 3] = ["oauth_token", "oauth_refresh_token", "connect_session"]; + +/// Which cookie dialect a store uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// Firefox family: plaintext `value` in `moz_cookies`. + Gecko, + /// Chromium family: `encrypted_value` in `cookies`, AES-128-CBC under the public Linux key. + Chromium, +} + +#[derive(Debug)] +pub struct Store { + pub kind: Kind, + pub path: PathBuf, +} + +/// Hand the sign-in page to the system default browser. True when the launcher spawned; the +/// user still has to actually sign in there. +pub fn open_in_default_browser() -> bool { + let mut command = std::process::Command::new("xdg-open"); + command + .arg(SIGN_IN_URL) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + match command.spawn() { + Ok(mut child) => { + // xdg-open usually exits immediately, but a chooser dialog may linger; reap it on a + // blocking thread so a tokio worker never parks in `wait`. + std::thread::spawn(move || { + let _ = child.wait(); + }); + true + } + Err(error) => { + tracing::warn!(error = %error, "could not launch the default browser for SoundCloud sign-in"); + false + } + } +} + +/// Try every discovered cookie store and return the first complete, parseable SoundCloud +/// session. Cheap enough to poll: a store whose database (or WAL journal) has not been +/// touched since the last scan is skipped — a running browser writes every cookie change +/// through the WAL, so unchanged mtimes mean unchanged cookies. Side-effect free. +pub fn import_any() -> Option { + import_from(&discover()) +} + +fn import_from(stores: &[Store]) -> Option { + for store in stores { + if !changed_since_last_look(&store.path) { + continue; + } + let cookies = match store.kind { + Kind::Gecko => read_gecko(&store.path), + Kind::Chromium => read_chromium(&store.path), + }; + let Some(cookies) = cookies else { continue }; + if let Some(auth) = harvest(cookies) { + tracing::info!(store = %store.path.display(), "imported a SoundCloud session from the browser"); + return Some(auth); + } + } + None +} + +/// True when a store's database or WAL journal changed since the previous scan (or was never +/// scanned / cannot be stat'ed — err toward reading it). The memo is process-wide and only +/// ever consulted by the sign-in poller, so a stale entry costs one re-read, never a miss. +fn changed_since_last_look(path: &Path) -> bool { + static SEEN: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| parking_lot::Mutex::new(BTreeMap::new())); + let stamp = |p: &Path| { + std::fs::metadata(&p) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + // Nanoseconds: a write that lands after our stat has a strictly later mtime on + // every local filesystem; second granularity could miss one inside the tick. + .map(|d| d.as_nanos() as i64) + .unwrap_or(-1) + }; + let current = (stamp(path), stamp(&PathBuf::from(format!("{}-wal", path.display())))); + let mut seen = SEEN.lock(); + if seen.get(path) == Some(¤t) { + return false; + } + seen.insert(path.to_path_buf(), current); + true +} + +/// Pick the token triple out of raw `(name, value, domain)` cookie rows. Domain-matched by +/// hand: anything outside soundcloud.com is dropped even if a store somehow returned it. +pub fn harvest(cookies: Vec<(String, String, String)>) -> Option { + let mut jar = BTreeMap::new(); + for (name, value, domain) in cookies { + let domain = domain.strip_prefix('.').map(str::to_owned).unwrap_or(domain); + if domain != "soundcloud.com" && !domain.ends_with(".soundcloud.com") { + continue; + } + if COOKIE_NAMES.contains(&name.as_str()) { + jar.insert(name, value); + } + } + let access = jar.remove("oauth_token")?; + SoundcloudAuth::from_access_token( + access, + jar.remove("oauth_refresh_token"), + jar.remove("connect_session"), + ) +} + +// --- discovery -------------------------------------------------------------------------------- + +/// Every plausible browser config root: `$XDG_CONFIG_HOME`, `~/.mozilla`, and each Flatpak app's +/// private config/home (which is where sandboxed Firefox and Zen actually keep their profiles). +fn candidate_roots() -> Vec { + let home = home_dir(); + let config = std::env::var_os("XDG_CONFIG_HOME") + .filter(|p| Path::new(p).is_absolute()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")); + let mut roots = vec![config, home.join(".mozilla")]; + if let Ok(entries) = std::fs::read_dir(home.join(".var/app")) { + for entry in entries.flatten() { + let app = entry.path(); + roots.push(app.join("config")); + roots.push(app.join(".config")); + roots.push(app.join(".mozilla")); + } + } + if let Ok(entries) = std::fs::read_dir(home.join("snap")) { + for entry in entries.flatten() { + roots.push(entry.path().join("common/.mozilla")); + roots.push(entry.path().join("common/.config")); + } + } + roots +} + +/// All cookie databases that could hold a SoundCloud session, Gecko stores first (their values +/// are readable without any key material). Bounded so a wild home directory cannot blow up a +/// poll tick. +pub fn discover() -> Vec { + let mut gecko = Vec::new(); + let mut chromium = Vec::new(); + for root in candidate_roots() { + find_named(&root, "cookies.sqlite", 4, &mut gecko); + // Chromium-family profiles keep Cookies under the profile root or its Network/ dir. + find_named(&root, "Cookies", 5, &mut chromium); + } + chromium.retain(|path| { + // A file named "Cookies" is only interesting below a browser-ish profile directory; + // this also drops the Network/ duplicates' parent noise. + path.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some("Default") | Some("Network") | Some("chromium") | Some("google-chrome") + ) + }) + }); + let mut stores: Vec = gecko + .into_iter() + .map(|path| Store { kind: Kind::Gecko, path }) + .chain(chromium.into_iter().map(|path| Store { kind: Kind::Chromium, path })) + .collect(); + stores.sort_by(|a, b| a.path.cmp(&b.path)); + stores.truncate(64); + prefer_default_browser(&mut stores); + stores +} + +/// The user's default browser is the most likely place they just signed in; try it first. +fn prefer_default_browser(stores: &mut [Store]) { + let Ok(output) = + std::process::Command::new("xdg-settings").args(["get", "default-web-browser"]).output() + else { + return; + }; + if !output.status.success() { + return; + } + let desktop = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + // "zen.desktop" / "firefox.desktop" / "chromium.desktop" … match path fragments. + let name = desktop.trim_end_matches(".desktop").to_lowercase(); + if name.is_empty() { + return; + } + stores.sort_by_key(|store| { + let lower = store.path.to_string_lossy().to_lowercase(); + if lower.contains(&name) { + 0 + } else { + 1 + } + }); +} + +fn find_named(root: &Path, filename: &str, max_depth: usize, out: &mut Vec) { + if max_depth == 0 { + return; + } + let Ok(entries) = std::fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { continue }; + if file_type.is_file() { + if path.file_name().is_some_and(|n| n == filename) { + out.push(path); + } + } else if file_type.is_dir() && !path_is_symlink(&path) { + find_named(&path, filename, max_depth - 1, out); + } + } +} + +fn path_is_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path).map(|m| m.file_type().is_symlink()).unwrap_or(false) +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")) +} + +// --- readers ---------------------------------------------------------------------------------- + +/// Copy a (possibly locked, WAL-backed) SQLite database beside its journal files and open the +/// copy read-only, so a running browser never blocks or corrupts the import. +fn copy_and_open(path: &Path) -> Option<(tempfile::TempDir, Connection)> { + let dir = tempfile::Builder::new().prefix("ryotunes-cookies-").tempdir().ok()?; + let target = dir.path().join(path.file_name()?); + std::fs::copy(path, &target).ok()?; + for suffix in ["-wal", "-shm", "-journal"] { + let side = PathBuf::from(format!("{}{suffix}", path.to_string_lossy())); + if side.exists() { + let _ = std::fs::copy(&side, dir.path().join(side.file_name()?)); + } + } + let connection = Connection::open_with_flags( + &target, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + Some((dir, connection)) +} + +fn read_gecko(path: &Path) -> Option> { + let (_dir, connection) = copy_and_open(path)?; + let mut statement = connection + .prepare( + "SELECT name, value, host FROM moz_cookies \ + WHERE host LIKE '%soundcloud.com' AND name IN (?1, ?2, ?3)", + ) + .ok()?; + let rows = statement + .query_map(rusqlite::params![COOKIE_NAMES[0], COOKIE_NAMES[1], COOKIE_NAMES[2]], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + }) + .ok()?; + Some(rows.flatten().collect()) +} + +fn read_chromium(path: &Path) -> Option> { + let (_dir, connection) = copy_and_open(path)?; + let mut statement = connection + .prepare( + "SELECT name, host_key, encrypted_value, value FROM cookies \ + WHERE host_key LIKE '%soundcloud.com' AND name IN (?1, ?2, ?3)", + ) + .ok()?; + let rows = statement + .query_map(rusqlite::params![COOKIE_NAMES[0], COOKIE_NAMES[1], COOKIE_NAMES[2]], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, Vec>(3)?, + )) + }) + .ok()?; + let mut out = Vec::new(); + for row in rows.flatten() { + let (name, host, encrypted, plain) = row; + if let Some(value) = chromium_value(&encrypted, &plain, &host) { + out.push((name, value, host)); + } + } + Some(out) +} + +/// Decode a Chromium cookie value. Linux Chromium stores `v10` values as AES-128-CBC under a +/// key derived from the public passphrase "peanuts" (the whole point is per-user file +/// permissions, not secrecy from the user's own processes); some builds keep plaintext in +/// `value`. `v11` (a keyring-held key) is not attemptable here and is skipped. +fn chromium_value(encrypted: &[u8], plain: &[u8], host: &str) -> Option { + if let Some(text) = non_utf8_none(plain) { + return Some(text); + } + if encrypted.starts_with(b"v10") { + let decrypted = chromium_decrypt_v10(&encrypted[3..])?; + // Some versions prefix the SHA-256 of the cookie host to the plaintext. + let host_hash = sha256(host.trim_start_matches('.')); + let body = if decrypted.len() > 32 && decrypted[..32] == host_hash[..] { + &decrypted[32..] + } else { + &decrypted[..] + }; + return String::from_utf8(body.to_vec()).ok(); + } + if !encrypted.is_empty() && !encrypted.starts_with(b"v1") { + return String::from_utf8(encrypted.to_vec()).ok(); + } + None +} + +fn non_utf8_none(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + String::from_utf8(bytes.to_vec()).ok() +} + +fn chromium_decrypt_v10(ciphertext: &[u8]) -> Option> { + use aes::Aes128; + use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + type Aes128CbcDec = cbc::Decryptor; + + let mut key = [0u8; 16]; + pbkdf2::pbkdf2_hmac::(b"peanuts", b"saltysalt", 1, &mut key); + let decryptor = Aes128CbcDec::new(&key.into(), &[0x20; 16].into()); + decryptor.decrypt_padded_vec_mut::(ciphertext).ok() +} + +fn sha256(input: &str) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(input).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_jwt(expiry: i64) -> String { + let header = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + br#"{"alg":"RS256"}"#, + ); + let payload = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + format!(r#"{{"exp":{expiry},"client_id":"cid"}}"#).as_bytes(), + ); + format!("{header}.{payload}.signature") + } + + #[test] + fn harvest_takes_the_soundcloud_triple_and_ignores_the_rest() { + let expiry = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64 + + 3600; + let cookies = vec![ + ("oauth_token".into(), fake_jwt(expiry), ".soundcloud.com".into()), + ("oauth_refresh_token".into(), "rot-1".into(), ".soundcloud.com".into()), + ("connect_session".into(), "cs-1".into(), ".soundcloud.com".into()), + ("spam".into(), "no".into(), ".evil.com".into()), + ("oauth_token".into(), "wrong-domain".into(), ".notsoundcloud.com".into()), + ]; + let auth = harvest(cookies).expect("a complete triple harvests"); + assert_eq!(auth.refresh_token.as_deref(), Some("rot-1")); + assert_eq!(auth.connect_session.as_deref(), Some("cs-1")); + assert_eq!(auth.client_id, "cid"); + } + + #[test] + fn harvest_needs_the_access_token() { + assert!(harvest(vec![("connect_session".into(), "cs".into(), ".soundcloud.com".into())]) + .is_none()); + } + + fn seed_gecko(root: &Path, profile: &str, rows: &[(&str, &str, &str)]) -> PathBuf { + let dir = root.join(profile); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cookies.sqlite"); + let connection = Connection::open(&path).unwrap(); + connection + .execute("CREATE TABLE moz_cookies (host TEXT, name TEXT, value TEXT)", []) + .unwrap(); + for (host, name, value) in rows { + connection + .execute( + "INSERT INTO moz_cookies VALUES (?1, ?2, ?3)", + rusqlite::params![host, name, value], + ) + .unwrap(); + } + path + } + + #[test] + fn gecko_store_imports_over_a_locked_live_database() { + let temp = tempfile::tempdir().unwrap(); + let expiry = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64 + + 3600; + let path = seed_gecko( + temp.path(), + "abc123.Default (release)", + &[ + (".soundcloud.com", "oauth_token", &fake_jwt(expiry)), + (".soundcloud.com", "oauth_refresh_token", "rot"), + (".soundcloud.com", "connect_session", "cs"), + (".example.com", "oauth_token", "decoy"), + ], + ); + // Hold the source open (like a running browser) to prove the copy path works anyway. + let _live = Connection::open(&path).unwrap(); + let stores = vec![Store { kind: Kind::Gecko, path }]; + let auth = import_from(&stores).expect("the triple imports"); + assert_eq!(auth.refresh_token.as_deref(), Some("rot")); + } + + #[test] + fn chromium_v10_values_decrypt_with_the_public_linux_key() { + use aes::Aes128; + use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; + type Aes128CbcEnc = cbc::Encryptor; + + let mut key = [0u8; 16]; + pbkdf2::pbkdf2_hmac::(b"peanuts", b"saltysalt", 1, &mut key); + let encrypt = |plain: &[u8]| -> Vec { + Aes128CbcEnc::new(&key.into(), &[0x20; 16].into()) + .encrypt_padded_vec_mut::(plain) + }; + + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("chromium/Default"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Cookies"); + let connection = Connection::open(&path).unwrap(); + connection + .execute( + "CREATE TABLE cookies (host_key TEXT, name TEXT, encrypted_value BLOB, value BLOB)", + [], + ) + .unwrap(); + let expiry = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64 + + 3600; + let jwt = fake_jwt(expiry); + connection + .execute( + "INSERT INTO cookies VALUES ('.soundcloud.com', 'oauth_token', ?1, x'')", + rusqlite::params![format!("v10") + .into_bytes() + .into_iter() + .chain(encrypt(jwt.as_bytes())) + .collect::>()], + ) + .unwrap(); + connection + .execute( + "INSERT INTO cookies VALUES ('.soundcloud.com', 'oauth_refresh_token', ?1, x'')", + rusqlite::params!["v10" + .as_bytes() + .iter() + .cloned() + .chain(encrypt(b"rot-c")) + .collect::>()], + ) + .unwrap(); + connection + .execute( + "INSERT INTO cookies VALUES ('.soundcloud.com', 'connect_session', ?1, x'')", + rusqlite::params!["v10" + .as_bytes() + .iter() + .cloned() + .chain(encrypt(b"cs-c")) + .collect::>()], + ) + .unwrap(); + drop(connection); + + let stores = vec![Store { kind: Kind::Chromium, path }]; + let auth = import_from(&stores).expect("v10 cookies import"); + assert_eq!(auth.refresh_token.as_deref(), Some("rot-c")); + assert_eq!(auth.connect_session.as_deref(), Some("cs-c")); + } + + #[test] + fn v11_keyring_cookies_are_skipped_not_fatal() { + assert_eq!(chromium_value(b"v11garbage", b"", ".soundcloud.com"), None); + } + + #[test] + fn discovery_finds_gecko_and_chromium_layouts_under_a_fake_home() { + let temp = tempfile::tempdir().unwrap(); + let expiry = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + as i64 + + 3600; + seed_gecko( + &temp.path().join("zen"), + "xyz.Default (release)", + &[(".soundcloud.com", "oauth_token", &fake_jwt(expiry))], + ); + let chrome_dir = temp.path().join("chromium/Default/Network"); + std::fs::create_dir_all(&chrome_dir).unwrap(); + std::fs::write(chrome_dir.join("Cookies"), b"not-a-db").unwrap(); + + let mut gecko = Vec::new(); + let mut chromium = Vec::new(); + find_named(temp.path(), "cookies.sqlite", 4, &mut gecko); + find_named(temp.path(), "Cookies", 5, &mut chromium); + assert_eq!(gecko.len(), 1, "the zen profile db is found"); + assert!(gecko[0].to_string_lossy().contains("zen")); + assert_eq!(chromium.len(), 1, "the chromium Network/Cookies is found"); + } +} diff --git a/crates/ryotunesd/src/diagnostics.rs b/crates/ryotunesd/src/diagnostics.rs new file mode 100644 index 0000000..07ef992 --- /dev/null +++ b/crates/ryotunesd/src/diagnostics.rs @@ -0,0 +1,545 @@ +//! Diagnostics: one rolling record of everything that went wrong, for both halves of the app. +//! +//! Every `warn`/`error` the daemon logs (tracing), every RPC that answered an error (the server +//! records it at the one chokepoint all responses pass), every panic (the hook below), and every +//! failure the *client* saw (the `log_client_error` RPC — including failures the client buffers +//! locally while the daemon is down) land in one ring buffer and one rotating log file under +//! `$XDG_DATA_HOME/dev.ryoku.ryotunes/logs/`. The Settings ▸ Diagnostics page reads them back +//! through `get_logs`, so "some users can't play anything" becomes a list of lines instead of a +//! guess. The file survives restarts (that is the point: a crash loop still leaves evidence); +//! the ring is per-process and always available even if the disk is the thing that is broken. + +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; + +use serde_json::{json, Value}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +/// Ring-buffer cap. Two thousand lines is far more than anyone reads at once but small enough to +/// snapshot into every `get_logs` without thinking. +const RING_MAX: usize = 2000; +/// Rotate the log file at 2 MiB, keeping one previous generation (≈4 MiB worst case). +const FILE_MAX_BYTES: u64 = 2 * 1024 * 1024; +const FILE_NAME: &str = "ryotunesd.log"; +const ROTATED_NAME: &str = "ryotunesd.log.1"; +/// Bound one logged message so a pathological error (a giant upstream body) cannot poison the +/// ring or the file. +const MESSAGE_MAX_CHARS: usize = 2000; + +#[derive(Clone, Debug)] +pub struct Entry { + pub ts_ms: i64, + pub level: &'static str, + pub source: &'static str, + pub target: String, + pub message: String, +} + +impl Entry { + fn to_json(&self) -> Value { + json!({ + "ts": self.ts_ms, + "level": self.level, + "source": self.source, + "target": self.target, + "message": self.message, + }) + } + + fn to_line(&self) -> String { + format!( + "{} [{}] {} {}: {}\n", + format_utc(self.ts_ms), + self.level.to_uppercase(), + self.source, + self.target, + self.message, + ) + } +} + +struct Shared { + entries: VecDeque, + path: PathBuf, + bytes_written: u64, + errors: u64, + warnings: u64, +} + +/// The rolling record. Cheap to clone an `Arc` of; installed once at startup. +pub struct Diagnostics { + shared: Mutex, +} + +static GLOBAL: OnceLock> = OnceLock::new(); + +impl Diagnostics { + /// Open (or create) the log file under `logs_dir`, install this as the global recorder, + /// and seed the ring from the tail of the existing file. A daemon that died (or was + /// upgraded) leaves its evidence behind: the first `get_logs` after a crash already shows + /// what the previous process recorded, without the client having to read files. + pub fn install(logs_dir: &Path) -> std::sync::Arc { + let _ = std::fs::create_dir_all(logs_dir); + let path = logs_dir.join(FILE_NAME); + let bytes_written = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let mut entries = VecDeque::new(); + let mut errors = 0u64; + let mut warnings = 0u64; + for entry in read_tail(&path, RING_MAX) { + match entry.level { + "error" => errors += 1, + "warn" => warnings += 1, + _ => {} + } + entries.push_back(entry); + } + let di = std::sync::Arc::new(Diagnostics { + shared: Mutex::new(Shared { entries, path, bytes_written, errors, warnings }), + }); + let _ = GLOBAL.set(di.clone()); + di + } + + pub fn record(&self, level: &'static str, source: &'static str, target: &str, message: &str) { + let message = truncate(message, MESSAGE_MAX_CHARS); + let entry = + Entry { ts_ms: now_ms(), level, source, target: truncate(target, 120), message }; + let mut shared = self.shared.lock(); + match level { + "error" => shared.errors += 1, + "warn" => shared.warnings += 1, + _ => {} + } + if shared.entries.len() >= RING_MAX { + shared.entries.pop_front(); + } + let line = entry.to_line(); + shared.append_to_file(&line); + shared.entries.push_back(entry); + } + + /// Filtered, newest-first snapshot for the client. `level`: "all" | "warn" | "error"; + /// `source`: "all" | "daemon" | "client"; `query`: case-insensitive substring. + pub fn snapshot(&self, level: &str, source: &str, query: &str, limit: usize) -> Value { + let shared = self.shared.lock(); + let needle = query.to_lowercase(); + let mut out = Vec::new(); + for entry in shared.entries.iter().rev() { + if level == "warn" && entry.level == "info" { + continue; + } + if level == "error" && entry.level != "error" { + continue; + } + if source != "all" && entry.source != source { + continue; + } + if !needle.is_empty() + && !entry.message.to_lowercase().contains(&needle) + && !entry.target.to_lowercase().contains(&needle) + { + continue; + } + out.push(entry.to_json()); + if out.len() >= limit { + break; + } + } + json!({ + "entries": out, + "total": shared.entries.len(), + "errors": shared.errors, + "warnings": shared.warnings, + "path": shared.path.to_string_lossy(), + "fileBytes": shared.bytes_written, + }) + } + + /// Everything currently in the ring, oldest-first, as log-file lines (what `open_logs_folder` + /// complements: the file may be older than the ring after a rotation). + pub fn path(&self) -> PathBuf { + self.shared.lock().path.clone() + } +} + +impl Shared { + fn append_to_file(&mut self, line: &str) { + // Rotate first if this append would cross the cap: rename the current file to .1 + // (replacing any previous .1) and start fresh. A rename failure just keeps appending; + // losing a rotation is never a reason to lose the line. + if self.bytes_written >= FILE_MAX_BYTES { + let rotated = self.path.with_file_name(ROTATED_NAME); + if std::fs::rename(&self.path, &rotated).is_ok() { + self.bytes_written = 0; + } + } + match std::fs::OpenOptions::new().create(true).append(true).open(&self.path) { + Ok(mut file) => { + if file.write_all(line.as_bytes()).is_ok() { + let _ = file.flush(); + self.bytes_written += line.len() as u64; + } + } + // The disk being unwritable is exactly the kind of failure worth keeping in the + // ring; drop the file half silently and carry on serving from memory. + Err(_) => {} + } + } +} + +/// A tracing layer that feeds warn+ events into the diagnostics ring and file. Installed +/// alongside the stderr formatter so `journalctl` keeps working unchanged. +pub struct CaptureLayer; + +impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + if meta.level() > &Level::WARN { + return; + } + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + let level = if meta.level() == &Level::ERROR { "error" } else { "warn" }; + record(level, "daemon", meta.target(), &visitor.render()); + } +} + +#[derive(Default)] +struct MessageVisitor { + message: String, + error: Option, +} + +impl MessageVisitor { + fn render(self) -> String { + match (self.message.is_empty(), self.error) { + (true, Some(e)) => e, + (true, None) => String::new(), + (false, None) => self.message, + (false, Some(e)) => format!("{} (error: {e})", self.message), + } + } +} + +impl Visit for MessageVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_owned(); + } else if field.name() == "error" { + self.error = Some(value.to_owned()); + } + } + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{value:?}"); + } else if field.name() == "error" { + self.error = Some(format!("{value:?}")); + } + } +} + +/// The global entry point: everything (tracing layer, server chokepoint, panic hook, client RPC) +/// records through here. Before `install` runs there is nowhere to record; those early failures +/// still reach stderr via the formatter. +pub fn record(level: &'static str, source: &'static str, target: &str, message: &str) { + if let Some(di) = GLOBAL.get() { + di.record(level, source, target, message); + } +} + +pub fn snapshot(level: &str, source: &str, query: &str, limit: usize) -> Value { + GLOBAL.get().map(|di| di.snapshot(level, source, query, limit)).unwrap_or_else(|| { + json!({ + "entries": [], "total": 0, "errors": 0, "warnings": 0, + "path": "", "fileBytes": 0, + }) + }) +} + +pub fn logs_path() -> Option { + GLOBAL.get().map(|di| di.path()) +} + +/// Reveal the log folder in the user's file manager. The Settings page offers this beside the +/// viewer so "send me the logs" is a click, not a path hunt. +pub fn open_logs_folder() -> Result<(), String> { + let path = logs_path().ok_or("Logging is not initialised yet.")?; + let dir = path.parent().ok_or("The log file has no folder.")?; + let mut child = std::process::Command::new("xdg-open") + .arg(dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|error| format!("Could not open the logs folder: {error}"))?; + // A file manager may stay open for hours; reap it on a blocking thread so a tokio worker + // never parks inside `wait`. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +/// Route panics into the log before the default hook prints them. A daemon that dies on launch +/// (the worst support case) leaves the panic line in the file for the next session's UI to show. +pub fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let location = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "unknown".into()); + let payload = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".into()); + record("error", "daemon", &format!("panic at {location}"), &payload); + previous(info); + })); +} + +fn truncate(input: &str, max: usize) -> String { + if input.chars().count() <= max { + return input.to_owned(); + } + let mut out: String = input.chars().take(max).collect(); + out.push('…'); + out +} + +fn now_ms() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64 +} + +/// `2026-09-24T19:12:03Z` from Unix millis (civil-from-days; no chrono for one formatter). +fn format_utc(ts_ms: i64) -> String { + let secs = ts_ms.div_euclid(1000); + let millis = ts_ms.rem_euclid(1000); + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z", + rem / 3600, + (rem % 3600) / 60, + rem % 60, + ) +} + +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// The last `max` lines of the log file, in file order (oldest first). A missing or +/// unreadable file seeds nothing (the ring simply starts empty); a line that does not parse +/// is skipped, never fatal — the file is evidence, not a contract. +fn read_tail(path: &Path, max: usize) -> Vec { + let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() }; + let mut out: Vec = text.lines().rev().filter_map(parse_line).collect(); + out.reverse(); + if out.len() > max { + out.drain(0..out.len() - max); + } + out +} + +/// `2026-09-24T19:12:03.456Z [ERROR] daemon rpc:play: message…` back into an entry. +fn parse_line(line: &str) -> Option { + let (stamp, rest) = line.split_once(" [")?; + let ts_ms = parse_utc(stamp)?; + let (level, rest) = rest.split_once("] ")?; + let level = match level.to_ascii_lowercase().as_str() { + "error" => "error", + "warn" => "warn", + "info" => "info", + _ => return None, + }; + let (origin, message) = rest.split_once(": ")?; + let (source, target) = origin.split_once(' ')?; + let source = match source { + "daemon" => "daemon", + "client" => "client", + _ => return None, + }; + Some(Entry { ts_ms, level, source, target: target.to_owned(), message: message.to_owned() }) +} + +/// The inverse of [`format_utc`] (same civil algorithm, reversed). +fn parse_utc(stamp: &str) -> Option { + // YYYY-MM-DDThh:mm:ss.mmmZ + if stamp.len() != 24 { + return None; + } + let b = stamp.as_bytes(); + if b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + || b[19] != b'.' + || b[23] != b'Z' + { + return None; + } + let num = |s: &str| s.parse::().ok(); + let (y, mo, d) = (num(&stamp[0..4])?, num(&stamp[5..7])?, num(&stamp[8..10])?); + let h = num(&stamp[11..13])?; + let mi = num(&stamp[14..16])?; + let s = num(&stamp[17..19])?; + let ms = num(&stamp[20..23])?; + let days = days_from_civil(y, mo as u32, d as u32); + Some(((days * 86_400 + h * 3600 + mi * 60 + s) * 1000) + ms) +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if m > 2 { m - 3 } else { m + 9 } as i64; + let doy = (153 * mp + 2) / 5 + d as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Dir(PathBuf); + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn temp_dir(tag: &str) -> Dir { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "ryotunes-diag-{tag}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )); + std::fs::create_dir_all(&path).unwrap(); + Dir(path) + } + + #[test] + fn records_filter_and_count_by_level_and_source() { + let dir = temp_dir("filter"); + let di = Diagnostics::install(&dir.0); + di.record("error", "daemon", "resolve", "bot-gated stream"); + di.record("warn", "daemon", "spotify", "session dead"); + di.record("info", "daemon", "boot", "started"); + di.record("error", "client", "rpc:play", "daemon not connected"); + let all = di.snapshot("all", "all", "", 100); + assert_eq!(all["total"], 4); + assert_eq!(all["errors"], 2); + assert_eq!(all["warnings"], 1); + // newest first + assert_eq!(all["entries"][0]["source"], "client"); + + let errors = di.snapshot("error", "all", "", 100); + assert_eq!(errors["entries"].as_array().unwrap().len(), 2); + let daemon = di.snapshot("all", "daemon", "", 100); + assert_eq!(daemon["entries"].as_array().unwrap().len(), 3); + let search = di.snapshot("all", "all", "bot", 100); + assert_eq!(search["entries"].as_array().unwrap().len(), 1); + assert_eq!(search["entries"][0]["level"], "error"); + } + + #[test] + fn log_lines_round_trip_through_the_tail_reader() { + let dir = temp_dir("tail"); + { + let di = Diagnostics::install(&dir.0); + di.record("error", "daemon", "rpc:play", "bot-gated stream"); + di.record("warn", "client", "daemon", "connection lost"); + } + // A fresh install over the same file re-seeds the ring (the crash-restart case). + let di = Diagnostics::install(&dir.0); + let snap = di.snapshot("all", "all", "", 100); + assert_eq!(snap["total"], 2, "both previous entries survived the restart"); + assert_eq!(snap["errors"], 1); + assert_eq!(snap["warnings"], 1); + // snapshot is newest-first: the warn came second. + assert_eq!(snap["entries"][0]["target"], "daemon"); + assert_eq!(snap["entries"][0]["message"], "connection lost"); + assert_eq!(snap["entries"][1]["message"], "bot-gated stream"); + // Unparsable lines are skipped rather than poisoning the seed. + std::fs::write( + dir.0.join(FILE_NAME), + b"garbage line\n2026-09-24T19:12:03.456Z [ERROR] daemon x: hello\n", + ) + .unwrap(); + let di = Diagnostics::install(&dir.0); + let snap = di.snapshot("all", "all", "", 100); + assert_eq!(snap["total"], 1, "unparsable lines are skipped"); + assert_eq!(snap["entries"][0]["ts"], 1_790_277_123_456i64, "timestamp round-trips"); + } + + #[test] + fn entries_land_in_the_log_file_and_rotate_once() { + let dir = temp_dir("file"); + let di = Diagnostics::install(&dir.0); + for i in 0..20 { + di.record("error", "daemon", "test", &format!("failure number {i}")); + } + let log = dir.0.join(FILE_NAME); + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 20); + // Each line leads with the UTC timestamp: `YYYY-MM-DDThh:mm:ss.mmmZ [LEVEL] …`. + assert!( + text.lines().all(|l| l.as_bytes()[4] == b'-' && l.contains("Z [ERROR]")), + "timestamps are UTC ISO-like" + ); + + // Force a rotation by claiming the file is at the cap. + { + let mut shared = di.shared.lock(); + shared.bytes_written = FILE_MAX_BYTES; + } + di.record("error", "daemon", "test", "after rotation"); + assert!(dir.0.join(ROTATED_NAME).exists()); + let fresh = std::fs::read_to_string(&log).unwrap(); + assert_eq!(fresh.lines().count(), 1); + assert!(fresh.contains("after rotation")); + } + + #[test] + fn ring_drops_oldest_at_capacity() { + let dir = temp_dir("ring"); + let di = Diagnostics::install(&dir.0); + for i in 0..(RING_MAX + 50) { + di.record("warn", "daemon", "test", &format!("line {i}")); + } + let snap = di.snapshot("all", "all", "", RING_MAX + 10); + assert_eq!(snap["total"], RING_MAX); + assert_eq!(snap["entries"][0]["message"], format!("line {}", RING_MAX + 49)); + } + + #[test] + fn utc_formatting_matches_known_instants() { + // 2000-01-01T00:00:00Z = 946_684_800 s; the others check hour/day rollover. + assert_eq!(format_utc(946_684_800_000), "2000-01-01T00:00:00.000Z"); + assert_eq!(format_utc(1_709_635_200_123), "2024-03-05T10:40:00.123Z"); + assert_eq!(format_utc(1_709_268_800_000), "2024-03-01T04:53:20.000Z"); + } +} diff --git a/crates/ryotunesd/src/download_media.rs b/crates/ryotunesd/src/download_media.rs new file mode 100644 index 0000000..62e5404 --- /dev/null +++ b/crates/ryotunesd/src/download_media.rs @@ -0,0 +1,720 @@ +//! Local artwork + lyrics enrichment for finished downloads. +//! +//! Runs once, after yt-dlp reports success and before publication, only when `embed_metadata` is +//! on. Everything here is best-effort and bounded: a lookup that fails, times out, or finds no +//! confident match is a warning on the job's history, never a download failure. +//! +//! Cover art is taken from the track's own provider thumbnail first (only over HTTPS from the +//! catalogue CDNs the app already serves — never localhost, a private address, or an arbitrary +//! host). When that is unavailable it falls back to the free iTunes Search API, and accepts a +//! result only on an exact normalized title+artist match whose length is within five seconds of +//! the file we actually downloaded — not merely the first search hit, which ranks remixes and +//! live cuts next to the original. +//! +//! Lyrics reuse [`ryotunes_core::lyrics::get_lyrics`] and its cache/provider chain (LRCLIB and the +//! rest) under one overall timeout. The plain text is embedded in the audio tag; a synced `.lrc` +//! (or plain `.txt`) sidecar is written next to it. +//! +//! Tag mutation happens on an isolated temp copy inside the staging directory and only replaces +//! the staged audio on success, so a failed embed can never corrupt the download. No re-encode. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use lofty::config::WriteOptions; +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::picture::{MimeType, Picture, PictureType}; +use lofty::tag::{Accessor, ItemKey, Tag}; +use ryotunes_core::lyrics::{get_lyrics, Lyrics, LyricsRequest}; +use ryotunes_core::state::AppState; +use serde::Deserialize; + +use crate::downloads::DownloadJob; + +/// What enrichment produced: sidecar files written beside the staged audio (cover art and/or +/// lyrics) that publication should relocate next to the final track, plus best-effort warnings +/// worth surfacing in the job's history. +pub struct Enrichment { + pub sidecars: Vec, + pub warnings: Vec, +} + +/// Provider image CDNs the app itself already serves thumbnails from. Matched as domain suffixes +/// (leading dot), so `evil-scdn.co` and bare IPs/localhost never qualify. +const TRUSTED_ART_SUFFIXES: [&str; 5] = + [".ytimg.com", ".ggpht.com", ".googleusercontent.com", ".sndcdn.com", ".scdn.co"]; + +/// Apple's artwork CDN. The iTunes Search API returns URLs here; we still verify the host before +/// fetching rather than trusting whatever the response points at. +const MZSTATIC_SUFFIX: &str = ".mzstatic.com"; + +/// A search hit's length may sit at most this far from the file we downloaded. Same tolerance the +/// core lyrics matcher uses, for the same reason: catalogues rank remixes beside originals. +const MATCH_TOLERANCE_SECS: f64 = 5.0; + +const MAX_IMAGE_BYTES: usize = 12 * 1024 * 1024; +const MAX_JSON_BYTES: usize = 1024 * 1024; +const IMAGE_TIMEOUT: Duration = Duration::from_secs(15); +const API_TIMEOUT: Duration = Duration::from_secs(12); +/// Whole-artwork budget (provider thumbnail then the iTunes fallback chain). +const ARTWORK_TIMEOUT: Duration = Duration::from_secs(30); +/// Whole-lookup budget for the lyrics provider chain. +const LYRICS_TIMEOUT: Duration = Duration::from_secs(20); + +/// Validated cover art ready to embed and write as a sidecar. +struct Artwork { + bytes: Vec, + mime: MimeType, + ext: &'static str, +} + +/// Enrich a freshly downloaded, staged audio file with cover art and lyrics. +/// +/// `audio` is the staged `audio.` yt-dlp produced. Sidecars are written with +/// `audio.with_extension(...)` in the same directory and returned for publication. The staged +/// audio's title/artist are corrected to the catalogue values on `job` (yt-dlp tags them from the +/// uploader/video title), and artwork + plain lyrics are embedded when found. +pub async fn enrich(state: &AppState, audio: &Path, job: &DownloadJob) -> Enrichment { + let mut warnings = Vec::new(); + + // The real downloaded length, from the file itself — the trustworthy signal for matching. + let duration = read_duration(audio).await; + + let artwork_fut = async { + match tokio::time::timeout( + ARTWORK_TIMEOUT, + resolve_artwork(&job.thumbnail, &job.title, &job.artists, duration), + ) + .await + { + Ok(pair) => pair, + Err(_) => (None, Some("Cover art lookup timed out.".to_string())), + } + }; + let lyrics_fut = resolve_lyrics(state, job, duration); + let ((artwork, art_warn), (lyrics, lyric_warn)) = tokio::join!(artwork_fut, lyrics_fut); + warnings.extend(art_warn); + warnings.extend(lyric_warn); + + // Shape the lyric outputs: the tag always carries plain text; the sidecar is synced `.lrc` + // when we have timings, otherwise plain `.txt`. + let mut lyric_tag: Option = None; + let mut lyric_sidecar: Option<(PathBuf, String)> = None; + if let Some(found) = &lyrics { + let plain = plain_text(found); + if plain.trim().is_empty() { + warnings.push("No lyrics were found for this track.".to_string()); + } else { + lyric_tag = Some(plain.clone()); + lyric_sidecar = Some(match to_lrc(found) { + Some(lrc) => (audio.with_extension("lrc"), lrc), + None => (audio.with_extension("txt"), plain), + }); + } + } + + // File and tag work is blocking; do it off the async runtime and only report sidecars that + // were actually written. + let audio_buf = audio.to_path_buf(); + let title = job.title.clone(); + let artist = job.artists.clone(); + match tokio::task::spawn_blocking(move || { + write_media(&audio_buf, &title, &artist, artwork, lyric_tag, lyric_sidecar) + }) + .await + { + Ok((sidecars, mut blocking_warnings)) => { + warnings.append(&mut blocking_warnings); + Enrichment { sidecars, warnings } + } + Err(error) => { + warnings.push(format!("Metadata enrichment did not finish: {error}")); + Enrichment { sidecars: Vec::new(), warnings } + } + } +} + +/// The staged file's real length in seconds, or `None` when lofty can't read it (unknown, so +/// matching falls back to title+artist alone). +async fn read_duration(audio: &Path) -> Option { + let path = audio.to_path_buf(); + tokio::task::spawn_blocking(move || { + lofty::read_from_path(&path) + .ok() + .map(|tagged| tagged.properties().duration().as_secs_f64()) + .filter(|secs| *secs > 0.0) + }) + .await + .ok() + .flatten() +} + +/// Cover art, provider thumbnail first then the iTunes fallback. Returns the art plus an optional +/// honest warning when nothing usable was found. +async fn resolve_artwork( + thumbnail: &str, + title: &str, + artists: &str, + duration: Option, +) -> (Option, Option) { + if let Some(url) = trusted_thumbnail(thumbnail) { + match fetch_image(&url).await { + Ok(art) => return (Some(art), None), + Err(error) => { + tracing::debug!(%error, "download: provider thumbnail unusable, trying iTunes"); + } + } + } + match itunes_artwork(title, artists, duration).await { + Ok(Some(art)) => (Some(art), None), + Ok(None) => (None, Some("No matching cover art was found for this track.".to_string())), + Err(error) => { + tracing::debug!(%error, "download: iTunes artwork lookup failed"); + (None, Some("Cover art could not be downloaded.".to_string())) + } + } +} + +/// Lyrics via the core cache/provider chain under one overall timeout. Instrumental verdicts yield +/// no files and no warning (that is the expected answer, not a failure). +async fn resolve_lyrics( + state: &AppState, + job: &DownloadJob, + duration: Option, +) -> (Option, Option) { + let req = LyricsRequest { + video_id: job.video_id.clone(), + title: job.title.clone(), + artists: job.artists.clone(), + album: None, + duration, + }; + match tokio::time::timeout(LYRICS_TIMEOUT, get_lyrics(state, req)).await { + Ok(Some(lyrics)) if lyrics.instrumental => (None, None), + Ok(Some(lyrics)) => (Some(lyrics), None), + Ok(None) => (None, Some("No lyrics were found for this track.".to_string())), + Err(_) => (None, Some("Lyrics lookup timed out.".to_string())), + } +} + +/// A trusted, HTTPS provider thumbnail URL to fetch, or `None` when the URL is missing, insecure, +/// or points somewhere we won't fetch cover art from. +fn trusted_thumbnail(raw: &str) -> Option { + let raw = raw.trim(); + (!raw.is_empty() && trusted_image_host(raw, &TRUSTED_ART_SUFFIXES)).then(|| raw.to_string()) +} + +/// HTTPS URL whose host ends with one of `suffixes`. Rejects non-HTTPS, IP literals, localhost, +/// and suffix-spoofing hosts (`evil-scdn.co`) by requiring the dotted-suffix boundary. +fn trusted_image_host(raw: &str, suffixes: &[&str]) -> bool { + let Ok(url) = url::Url::parse(raw) else { return false }; + if url.scheme() != "https" { + return false; + } + match url.host_str() { + Some(host) => { + let host = host.to_ascii_lowercase(); + suffixes.iter().any(|suffix| host.len() > suffix.len() && host.ends_with(suffix)) + } + None => false, + } +} + +/// iTunes Search fallback. Picks the first result on an exact normalized title+artist match within +/// the duration window (when the length is known), then fetches and validates its cover. +async fn itunes_artwork( + title: &str, + artists: &str, + duration: Option, +) -> Result, String> { + let term = format!("{} {}", artists.trim(), title.trim()); + if term.trim().is_empty() { + return Ok(None); + } + let mut resp = ryotunes_core::http::client() + .get("https://itunes.apple.com/search") + .query(&[ + ("media", "music"), + ("entity", "song"), + ("limit", "25"), + ("country", "US"), + ("term", term.as_str()), + ]) + .timeout(API_TIMEOUT) + .send() + .await + .map_err(|error| format!("iTunes search failed: {error}"))?; + if !resp.status().is_success() { + return Err(format!("iTunes search returned HTTP {}", resp.status().as_u16())); + } + let mut buf = Vec::new(); + while let Some(chunk) = + resp.chunk().await.map_err(|error| format!("iTunes search failed: {error}"))? + { + if buf.len() + chunk.len() > MAX_JSON_BYTES { + return Err("iTunes response was too large".into()); + } + buf.extend_from_slice(&chunk[..]); + } + let parsed: ItunesResponse = serde_json::from_slice(&buf) + .map_err(|error| format!("iTunes response was not valid JSON: {error}"))?; + + let Some(chosen) = choose_itunes(&parsed.results, title, artists, duration) else { + return Ok(None); + }; + let Some(raw_art) = chosen.artwork_url100.as_deref() else { + return Ok(None); + }; + let art_url = upscale_itunes(raw_art); + if !trusted_image_host(&art_url, &[MZSTATIC_SUFFIX]) { + return Ok(None); + } + fetch_image(&art_url).await.map(Some) +} + +/// The one search hit that confidently identifies our track: exact normalized title, exact +/// normalized artist (whole credit or its primary artist), and — when we know our length — a +/// length within tolerance. A candidate with no length loses when ours is known, rather than being +/// accepted on title+artist alone. +fn choose_itunes<'a>( + results: &'a [ItunesResult], + title: &str, + artists: &str, + duration: Option, +) -> Option<&'a ItunesResult> { + let want_title = normalize(title); + let want_artist_full = normalize(artists); + let want_artist_primary = normalize(&primary_artist(artists)); + results.iter().find(|candidate| { + let (Some(track), Some(artist)) = + (candidate.track_name.as_deref(), candidate.artist_name.as_deref()) + else { + return false; + }; + if normalize(track) != want_title { + return false; + } + let their_artist = normalize(artist); + if their_artist != want_artist_full && their_artist != want_artist_primary { + return false; + } + match duration { + Some(ours) => match candidate.track_time_millis { + Some(ms) => (ms as f64 / 1000.0 - ours).abs() <= MATCH_TOLERANCE_SECS, + None => false, + }, + None => true, + } + }) +} + +/// Ask iTunes for a larger render of the standard 100px artwork URL. Untouched if the size token +/// isn't there. +fn upscale_itunes(url: &str) -> String { + url.replace("100x100bb", "600x600bb") +} + +/// Casefolded alphanumerics only — drops spacing and punctuation so "Song (Remix)" and "Song" +/// stay distinct while "The Weeknd" matches regardless of stray punctuation. +fn normalize(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() +} + +/// The first credited artist, before any secondary/feature separator. Used so a multi-artist +/// credit still matches iTunes' primary `artistName`. +fn primary_artist(artists: &str) -> String { + let trimmed = artists.trim(); + let lower = trimmed.to_ascii_lowercase(); + let mut end = trimmed.len(); + for sep in [",", ";", " & ", " feat.", " feat ", " ft.", " ft ", " x "] { + if let Some(index) = lower.find(sep) { + end = end.min(index); + } + } + trimmed[..end].trim().to_string() +} + +/// Download `url`, bounded by size and time, and confirm it is a JPEG or PNG by signature. +async fn fetch_image(url: &str) -> Result { + let bytes = fetch_bounded(url, MAX_IMAGE_BYTES, IMAGE_TIMEOUT).await?; + let mime = + detect_image(&bytes).ok_or("the downloaded cover art was not a JPEG or PNG image")?; + let ext = match mime { + MimeType::Png => "png", + _ => "jpg", + }; + Ok(Artwork { bytes, mime, ext }) +} + +/// A GET whose body is capped at `max` bytes (via `Content-Length` and while streaming) and whose +/// total time is capped by `timeout`. +async fn fetch_bounded(url: &str, max: usize, timeout: Duration) -> Result, String> { + let mut resp = ryotunes_core::http::client() + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|error| format!("request failed: {error}"))?; + if !resp.status().is_success() { + return Err(format!("host returned HTTP {}", resp.status().as_u16())); + } + if resp.content_length().is_some_and(|len| len > max as u64) { + return Err("response was too large".into()); + } + let mut buf = Vec::new(); + while let Some(chunk) = + resp.chunk().await.map_err(|error| format!("download failed: {error}"))? + { + if buf.len() + chunk.len() > max { + return Err("response was too large".into()); + } + buf.extend_from_slice(&chunk[..]); + } + Ok(buf) +} + +/// JPEG/PNG detection by magic bytes. Deliberately narrow: only these two formats are embedded and +/// written as sidecars. +fn detect_image(bytes: &[u8]) -> Option { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some(MimeType::Jpeg) + } else if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Some(MimeType::Png) + } else { + None + } +} + +/// Synced lyrics rendered as LRC (`[mm:ss.xx]text`). Lines without a timestamp are skipped; `None` +/// when the lyrics aren't synced or nothing timed survived. +fn to_lrc(lyrics: &Lyrics) -> Option { + if !lyrics.synced { + return None; + } + let mut out = String::new(); + for line in &lyrics.lines { + let Some(ms) = line.time_ms else { continue }; + let centis_total = ms / 10; + let centis = centis_total % 100; + let secs_total = centis_total / 100; + let secs = secs_total % 60; + let mins = secs_total / 60; + out.push_str(&format!("[{mins:02}:{secs:02}.{centis:02}]{}\n", line.text)); + } + (!out.is_empty()).then_some(out) +} + +/// The lyrics as plain text, one line per cue, for the audio tag and the plain sidecar. +fn plain_text(lyrics: &Lyrics) -> String { + let mut out = String::new(); + for (index, line) in lyrics.lines.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&line.text); + } + out +} + +/// Write the sidecars and embed the tags. Returns the sidecar paths that were actually written and +/// any warnings; a failed step degrades to a warning without failing the download. +fn write_media( + audio: &Path, + title: &str, + artist: &str, + artwork: Option, + lyric_tag: Option, + lyric_sidecar: Option<(PathBuf, String)>, +) -> (Vec, Vec) { + let mut sidecars = Vec::new(); + let mut warnings = Vec::new(); + + if let Some(art) = &artwork { + let path = audio.with_extension(art.ext); + match std::fs::write(&path, &art.bytes) { + Ok(()) => sidecars.push(path), + Err(error) => warnings.push(format!("Could not save the cover art file: {error}")), + } + } + if let Some((path, content)) = &lyric_sidecar { + match std::fs::write(path, content) { + Ok(()) => sidecars.push(path.clone()), + Err(error) => warnings.push(format!("Could not save the lyrics file: {error}")), + } + } + if let Err(error) = embed_tags(audio, title, artist, artwork, lyric_tag.as_deref()) { + warnings.push(format!("Could not embed metadata into the audio file: {error}")); + } + (sidecars, warnings) +} + +/// Embed title/artist (+ artwork/lyrics when present) on an isolated copy, replacing the staged +/// audio only if the whole write succeeds. A failure leaves the original file untouched. +fn embed_tags( + audio: &Path, + title: &str, + artist: &str, + artwork: Option, + lyrics: Option<&str>, +) -> Result<(), String> { + let ext = audio + .extension() + .and_then(|value| value.to_str()) + .ok_or("the audio file has no extension")?; + let temp = audio.with_extension(format!("rytag.{ext}")); + std::fs::copy(audio, &temp).map_err(|error| format!("could not stage a copy: {error}"))?; + + match embed_into(&temp, title, artist, artwork, lyrics) { + Ok(()) => std::fs::rename(&temp, audio) + .map_err(|error| format!("could not replace the staged audio: {error}")), + Err(error) => { + let _ = std::fs::remove_file(&temp); + Err(error) + } + } +} + +/// Apply the tags in place on `path`. Reuses the file's existing tag, adds the format's primary +/// tag when there is none, and writes without re-encoding. +fn embed_into( + path: &Path, + title: &str, + artist: &str, + artwork: Option, + lyrics: Option<&str>, +) -> Result<(), String> { + let mut tagged = + lofty::read_from_path(path).map_err(|error| format!("could not read tags: {error}"))?; + let primary = tagged.primary_tag_type(); + let write_type = if tagged.contains_tag_type(primary) { + primary + } else if let Some(first) = tagged.first_tag() { + first.tag_type() + } else { + tagged.insert_tag(Tag::new(primary)); + primary + }; + let tag = tagged.tag_mut(write_type).ok_or("no writable tag on the audio file")?; + + tag.set_title(title.to_string()); + tag.set_artist(artist.to_string()); + if let Some(art) = artwork { + tag.remove_picture_type(PictureType::CoverFront); + tag.push_picture(Picture::new_unchecked( + PictureType::CoverFront, + Some(art.mime), + None, + art.bytes, + )); + } + if let Some(text) = lyrics { + tag.insert_text(ItemKey::Lyrics, text.to_string()); + } + + tagged + .save_to_path(path, WriteOptions::default()) + .map_err(|error| format!("could not write tags: {error}")) +} + +#[derive(Deserialize)] +struct ItunesResponse { + #[serde(default)] + results: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ItunesResult { + #[serde(default)] + track_name: Option, + #[serde(default)] + artist_name: Option, + #[serde(default)] + track_time_millis: Option, + #[serde(default)] + artwork_url100: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use ryotunes_core::lyrics::LyricLine; + + #[test] + fn staged_tag_write_preserves_a_recognizable_audio_format() { + struct Temp(PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let nonce = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = Temp( + std::env::temp_dir().join(format!("ryotunes-tags-{}-{nonce}", std::process::id())), + ); + std::fs::create_dir(&dir.0).unwrap(); + let audio = dir.0.join("audio.wav"); + // One PCM sample: a real, minimal RIFF/WAVE file rather than a mocked tag writer. + let mut wav = b"RIFF".to_vec(); + wav.extend(38u32.to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(1u16.to_le_bytes()); + wav.extend(1u16.to_le_bytes()); + wav.extend(8000u32.to_le_bytes()); + wav.extend(16000u32.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(16u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend(2u32.to_le_bytes()); + wav.extend(0i16.to_le_bytes()); + std::fs::write(&audio, wav).unwrap(); + embed_tags(&audio, "Saved title", "Saved artist", None, None).unwrap(); + let tagged = lofty::read_from_path(&audio).unwrap(); + assert!(tagged.tags().iter().any(|tag| tag.title().as_deref() == Some("Saved title"))); + assert!(tagged.tags().iter().any(|tag| tag.artist().as_deref() == Some("Saved artist"))); + } + + fn result(track: &str, artist: &str, ms: Option, art: Option<&str>) -> ItunesResult { + ItunesResult { + track_name: Some(track.to_string()), + artist_name: Some(artist.to_string()), + track_time_millis: ms, + artwork_url100: art.map(str::to_string), + } + } + + #[test] + fn itunes_pick_needs_exact_title_and_rejects_the_remix_even_within_window() { + let results = vec![ + result("Blinding Lights (Remix)", "The Weeknd", Some(200_000), Some("remix")), + result("Blinding Lights", "The Weeknd", Some(200_040), Some("real")), + ]; + // The remix is inside the duration window too; only the exact title separates it. + let chosen = choose_itunes(&results, "Blinding Lights", "The Weeknd", Some(200.0)).unwrap(); + assert_eq!(chosen.artwork_url100.as_deref(), Some("real")); + } + + #[test] + fn itunes_pick_enforces_the_duration_window() { + let results = vec![result("Song", "Artist", Some(180_000), Some("x"))]; + assert!(choose_itunes(&results, "Song", "Artist", Some(200.0)).is_none()); + assert!(choose_itunes(&results, "Song", "Artist", Some(182.0)).is_some()); + } + + #[test] + fn itunes_pick_matches_primary_of_a_multi_artist_credit() { + let results = vec![result("Save Your Tears", "The Weeknd", Some(215_000), Some("y"))]; + assert!(choose_itunes( + &results, + "Save Your Tears", + "The Weeknd, Ariana Grande", + Some(215.0) + ) + .is_some()); + } + + #[test] + fn itunes_pick_wants_duration_when_ours_is_known_but_falls_back_to_title_artist_when_not() { + let results = vec![result("Song", "Artist", None, Some("z"))]; + // We know our length, the candidate hides its own: refuse rather than guess. + assert!(choose_itunes(&results, "Song", "Artist", Some(200.0)).is_none()); + // Our length is unknown: an exact title+artist is all there is to go on. + assert!(choose_itunes(&results, "Song", "Artist", None).is_some()); + } + + #[test] + fn itunes_pick_rejects_a_wrong_artist() { + let results = vec![result("Song", "Somebody Else", Some(200_000), Some("no"))]; + assert!(choose_itunes(&results, "Song", "Artist", Some(200.0)).is_none()); + } + + #[test] + fn lrc_encodes_timestamps_and_skips_untimed_lines() { + let lyrics = Lyrics { + source: "LRCLIB".into(), + synced: true, + instrumental: false, + lines: vec![ + LyricLine::simple(Some(0), "first".into()), + LyricLine::simple(Some(61_230), "second".into()), + LyricLine::simple(None, "no cue".into()), + ], + }; + assert_eq!(to_lrc(&lyrics).unwrap(), "[00:00.00]first\n[01:01.23]second\n"); + } + + #[test] + fn lrc_is_none_for_unsynced_lyrics() { + let lyrics = Lyrics { + source: "x".into(), + synced: false, + instrumental: false, + lines: vec![LyricLine::simple(None, "plain".into())], + }; + assert!(to_lrc(&lyrics).is_none()); + } + + #[test] + fn plain_text_joins_lines_with_newlines() { + let lyrics = Lyrics { + source: "x".into(), + synced: false, + instrumental: false, + lines: vec![ + LyricLine::simple(None, "one".into()), + LyricLine::simple(None, "two".into()), + ], + }; + assert_eq!(plain_text(&lyrics), "one\ntwo"); + } + + #[test] + fn image_detection_accepts_only_jpeg_and_png() { + assert!(matches!(detect_image(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]), Some(MimeType::Jpeg))); + assert!(matches!( + detect_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0]), + Some(MimeType::Png) + )); + assert!(detect_image(b"GIF89a1234").is_none()); + assert!(detect_image(b"").is_none()); + assert!(detect_image(&[]).is_none()); + } + + #[test] + fn thumbnail_trust_accepts_provider_cdns_over_https() { + assert!(trusted_thumbnail("https://i.ytimg.com/vi/abc/maxresdefault.jpg").is_some()); + assert!(trusted_thumbnail("https://lh3.googleusercontent.com/x=w512").is_some()); + assert!(trusted_thumbnail("https://yt3.ggpht.com/y=s512").is_some()); + assert!(trusted_thumbnail("https://i1.sndcdn.com/artworks-x-t500x500.jpg").is_some()); + assert!(trusted_thumbnail("https://i.scdn.co/image/abc").is_some()); + } + + #[test] + fn thumbnail_trust_rejects_insecure_local_and_spoofed_hosts() { + assert!(trusted_thumbnail("http://i.ytimg.com/vi/abc.jpg").is_none()); + assert!(trusted_thumbnail("https://127.0.0.1/cover.jpg").is_none()); + assert!(trusted_thumbnail("https://localhost/cover.jpg").is_none()); + assert!(trusted_thumbnail("https://evil.example.com/cover.jpg").is_none()); + assert!(trusted_thumbnail("https://evil-scdn.co/cover.jpg").is_none()); + assert!(trusted_thumbnail("").is_none()); + } + + #[test] + fn itunes_art_host_must_be_mzstatic() { + assert!(trusted_image_host( + "https://is1-ssl.mzstatic.com/image/thumb/x/600x600bb.jpg", + &[MZSTATIC_SUFFIX] + )); + assert!(!trusted_image_host("https://evil.com/600x600bb.jpg", &[MZSTATIC_SUFFIX])); + } + + #[test] + fn itunes_artwork_url_is_upscaled() { + assert_eq!( + upscale_itunes("https://is1-ssl.mzstatic.com/image/thumb/x/100x100bb.jpg"), + "https://is1-ssl.mzstatic.com/image/thumb/x/600x600bb.jpg" + ); + } +} diff --git a/crates/ryotunesd/src/downloads.rs b/crates/ryotunesd/src/downloads.rs new file mode 100644 index 0000000..2bc3353 --- /dev/null +++ b/crates/ryotunesd/src/downloads.rs @@ -0,0 +1,2059 @@ +//! Bounded, daemon-owned downloads. Slots live until child processes are reaped, settings are +//! frozen when a job is queued, and files are atomically published without replacing existing files. +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use ryotunes_core::db::now_secs; +use ryotunes_core::host::EventSink; +use ryotunes_core::spotify::{sc_track_id, spotify_track_id}; +use ryotunes_core::state::AppState; +use ryotunes_core::{local, radio}; +use ryotunes_soundcloud::SoundCloud; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use tokio::sync::Notify; + +use crate::lifecycle::Lifecycle; + +const STATE_FILE: &str = "downloads.json"; +const MAX_ACTIVE_QUEUED: usize = 200; +const MAX_HISTORY: usize = 200; +const EVENT_THROTTLE: Duration = Duration::from_millis(400); +const STDERR_TAIL_BYTES: usize = 8192; +const MAX_ERROR_LEN: usize = 600; +const MAX_COMPONENT_BYTES: usize = 120; +const STAGING_DIR: &str = ".ryotunes-incomplete"; +const INTERRUPTED_MSG: &str = + "Download interrupted when Ryotunes stopped. Retry to download it again."; +const JOB_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +/// Container/codec choice. `Original` keeps the source stream (no re-encode); `Mp3`/`Opus` +/// transcode with ffmpeg. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DownloadFormat { + Original, + Mp3, + Opus, +} + +/// Where a job is in its lifecycle. `queued`/`downloading` are active (cap-counted, lifecycle-busy); +/// the rest are terminal. `failed` and `cancelled` are retryable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DownloadStatus { + Queued, + Downloading, + Completed, + Failed, + Cancelled, +} + +impl DownloadStatus { + fn is_active(self) -> bool { + matches!(self, DownloadStatus::Queued | DownloadStatus::Downloading) + } +} + +/// The user-editable download settings. camelCase over the wire. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadSettings { + pub path: String, + pub format: DownloadFormat, + pub workers: u32, + pub organize_by_artist: bool, + pub embed_metadata: bool, +} + +impl DownloadSettings { + /// First-run defaults: the XDG Music dir's `Ryotunes/`, original format, one worker, organized + /// by artist with metadata embedded. Does not touch the filesystem — the folder is created on + /// the first `set_download_settings` or the first download. + fn defaults() -> Self { + DownloadSettings { + path: default_music_dir().to_string_lossy().into_owned(), + format: DownloadFormat::Original, + workers: 1, + organize_by_artist: true, + embed_metadata: true, + } + } +} + +/// One download, exactly as the client renders it. camelCase; timestamps are Unix seconds. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadJob { + pub id: String, + pub video_id: String, + pub title: String, + pub artists: String, + pub thumbnail: String, + pub status: DownloadStatus, + pub progress: u32, + pub file_path: Option, + pub error: Option, + pub source: String, + /// The album/playlist this track was batch-added from, when it was. The client groups + /// collection downloads under one card per collection using this label. `#[serde(default)]` + /// keeps queues persisted by older builds loadable. + #[serde(default)] + pub collection: Option, + #[serde(default)] + pub collection_kind: Option, + pub created_at: i64, + pub finished_at: Option, + #[serde(default = "DownloadSettings::defaults")] + pub settings: DownloadSettings, + /// Non-fatal metadata notes from the enrichment step (missing cover/lyrics, a tag that could + /// not be written). Shown in history; their presence never demotes a completed download. + #[serde(default)] + pub warnings: Vec, +} + +// --- source classification --------------------------------------------------------------------- + +/// Which catalogue a `videoId` came from, and therefore how the worker resolves it. +#[derive(Clone, Copy)] +enum Source { + YouTube, + SoundCloud, + Spotify, +} + +impl Source { + /// The display label the client shows; the Spotify one is explicit that it is a search match, + /// never a claim of the exact Spotify master. + fn label(self) -> &'static str { + match self { + Source::YouTube => "YouTube", + Source::SoundCloud => "SoundCloud", + Source::Spotify => "YouTube match for Spotify", + } + } +} + +/// Classify a `videoId`, rejecting the unsupported kinds with a message fit for a tooltip/toast. +fn classify(video_id: &str) -> Result { + if local::is_local_song(video_id) { + return Err("This track is already a local file on your computer.".into()); + } + if radio::is_radio_id(video_id) { + return Err("Radio stations are live streams and can't be downloaded.".into()); + } + if sc_track_id(video_id).is_some() { + return Ok(Source::SoundCloud); + } + if spotify_track_id(video_id).is_some() { + return Ok(Source::Spotify); + } + if is_youtube_id(video_id) { + return Ok(Source::YouTube); + } + Err("This track can't be downloaded.".into()) +} + +/// A strict YouTube video id: exactly 11 of `[A-Za-z0-9_-]`. Anything else is not a bare id and is +/// refused rather than handed to yt-dlp as an ambiguous URL/search. +fn is_youtube_id(id: &str) -> bool { + id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +// --- collection downloads + smart dedup ------------------------------------------------------ +/// One track a collection (playlist/album) offered for batch download. camelCase on the wire, +/// mirroring the download job's field names so the client sends what it already renders. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionEntry { + pub video_id: String, + pub title: String, + pub artists: String, + pub thumbnail: String, + /// The album/playlist name the batch came from; stamped onto each created job so the client + /// can group them. Optional so a caller may batch unlabelled tracks. + #[serde(default)] + pub collection: Option, + #[serde(default)] + pub collection_kind: Option, +} + +/// A snapshot of the download folder's audio files, used to refuse re-downloading a track that +/// is already saved — possibly under a different source id (a reupload, a remaster, or a file +/// named before Ryotunes appended the `[videoId]` tag). Stems map to their paths so a dedup hit +/// can hand the client the file it already owns. +struct FolderIndex { + /// Exact file stems (Ryotunes names always end in ` [videoId]`). + stems: HashMap, + /// The same names, lowercased with non-alphanumerics and the id tag removed: the "smart" + /// half — matching two uploads of the same `Artist - Title` across id changes. + keys: HashMap, + root: PathBuf, +} + +const INDEX_MAX_FILES: usize = 20_000; +const INDEX_MAX_DEPTH: usize = 6; + +impl FolderIndex { + fn build(root: &Path) -> FolderIndex { + let mut index = + FolderIndex { stems: HashMap::new(), keys: HashMap::new(), root: root.to_path_buf() }; + index.walk(root, 0); + index + } + + fn walk(&mut self, dir: &Path, depth: usize) { + if depth > INDEX_MAX_DEPTH || self.stems.len() >= INDEX_MAX_FILES { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + if self.stems.len() >= INDEX_MAX_FILES { + return; + } + // Skip our own staging dir, and never descend a symlink: `file_type` here does not + // follow links, so links surface as neither file nor dir and are simply ignored. + if entry.file_name().to_str().is_some_and(|n| n.starts_with(STAGING_DIR)) { + continue; + } + let Ok(file_type) = entry.file_type() else { continue }; + if file_type.is_dir() { + self.walk(&entry.path(), depth + 1); + } else if file_type.is_file() { + let name = entry.file_name(); + let Some(stem) = Path::new(&*name).file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let stem = stem.to_owned(); + let path = entry.path(); + self.stems.insert(stem.clone(), path.clone()); + self.keys.entry(normalize_key(strip_id_tag(&stem))).or_insert(path); + } + } + } + + /// The saved file for a candidate (title/artist/source id, named by the same rules `publish` + /// uses), if one exists: exact stem, any ` (N)` collision suffix, or the same normalized + /// "artist title" key under a different id. Paths are resolved against the folder root so + /// callers can report them directly. + fn find(&self, settings: &DownloadSettings, entry: &CollectionEntry) -> Option { + let base = if settings.organize_by_artist || entry.artists.trim().is_empty() { + entry.title.clone() + } else { + format!("{} - {}", entry.artists.trim(), entry.title.trim()) + }; + let name = sanitize_component(&base); + let stem = format!("{name} [{}]", sanitize_component(&entry.video_id)); + if let Some(path) = self.stems.get(&stem) { + return Some(self.relativize(path)); + } + for suffix in 1..=50u32 { + if let Some(path) = self.stems.get(&format!("{stem} ({suffix})")) { + return Some(self.relativize(path)); + } + } + let key = normalize_key(strip_id_tag(&stem)); + if key.is_empty() { + return None; + } + self.keys.get(&key).map(|path| self.relativize(path)) + } + + fn relativize(&self, path: &Path) -> PathBuf { + path.strip_prefix(&self.root).unwrap_or(path).to_path_buf() + } +} + +/// The trailing `[videoId]` tag Ryotunes appends to every published name, if present. +fn strip_id_tag(stem: &str) -> &str { + match stem.rfind(" [") { + Some(open) if stem.ends_with(']') && open + 2 < stem.len() => &stem[..open], + _ => stem, + } +} + +/// Lowercase alphanumeric only: the collision course for "Artist - Title", "artist–title", and a +/// name re-sanitized through a different character set. +fn normalize_key(input: &str) -> String { + input.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +struct Running { + cancel: Arc, +} + +struct State { + settings: DownloadSettings, + jobs: Vec, + running: HashMap, + stopping: bool, +} + +struct Inner { + sink: Arc, + soundcloud: Arc, + lifecycle: Arc, + /// Shared app state, so a finished download can reuse the core lyrics provider/cache and HTTP + /// client to fetch artwork and lyrics. `None` in unit tests, which never run enrichment. + app: Option>, + data_dir: PathBuf, + state: Mutex, + last_emit: Mutex, + finished: Notify, + id_seq: AtomicU64, +} + +pub struct Downloads { + inner: Arc, +} + +impl Downloads { + pub fn new( + sink: Arc, + soundcloud: Arc, + data_dir: PathBuf, + lifecycle: Arc, + app: Option>, + ) -> anyhow::Result> { + let (settings, mut jobs) = load(&data_dir)?; + recover_interrupted(&mut jobs); + trim_history(&mut jobs); + let inner = Arc::new(Inner { + sink, + soundcloud, + lifecycle, + app, + data_dir, + state: Mutex::new(State { settings, jobs, running: HashMap::new(), stopping: false }), + last_emit: Mutex::new(Instant::now() - EVENT_THROTTLE), + finished: Notify::new(), + id_seq: AtomicU64::new(0), + }); + inner.persist_locked(&inner.state.lock())?; + Ok(Arc::new(Self { inner })) + } + + pub fn settings(&self) -> DownloadSettings { + self.inner.state.lock().settings.clone() + } + pub fn snapshot(&self) -> Value { + self.inner.snapshot() + } + + pub fn set_settings(&self, incoming: DownloadSettings) -> Result { + let settings = sanitize_settings(incoming)?; + { + let mut state = self.inner.state.lock(); + ensure_running(&state)?; + let previous = std::mem::replace(&mut state.settings, settings.clone()); + if let Err(error) = self.inner.persist_locked(&state) { + state.settings = previous; + return Err(format!("Could not save download preferences: {error}")); + } + } + self.inner.pump(); + Ok(settings) + } + + pub fn enqueue( + &self, + video_id: String, + title: String, + artists: String, + thumbnail: String, + ) -> Result { + let source = classify(&video_id)?; + if video_id.len() > 80 || title.len() > 512 || artists.len() > 512 || thumbnail.len() > 4096 + { + return Err("The track metadata is too large to download safely.".into()); + } + if title.trim().is_empty() { + return Err("This track has no title to use for its download.".into()); + } + let job = { + let mut state = self.inner.state.lock(); + ensure_running(&state)?; + if let Some(job) = state.jobs.iter().rev().find(|job| { + job.video_id == video_id + && (job.status.is_active() + || (job.status == DownloadStatus::Completed && file_present(job))) + }) { + return Ok(job.clone()); + } + ensure_capacity(&state)?; + // Smart dedup beyond the job history (which is capped): a file already sitting in + // the download folder under this id, a collision suffix, or the same normalized + // title from a different upload counts as downloaded. The record returned is a + // description of the existing file, deliberately not part of the queue — the client + // renders `jobs`, so it never becomes a cancel/retry target. + { + let entry = CollectionEntry { + video_id: video_id.clone(), + title: title.clone(), + artists: artists.clone(), + thumbnail: thumbnail.clone(), + collection: None, + collection_kind: None, + }; + let index = FolderIndex::build(Path::new(&state.settings.path)); + if let Some(path) = index.find(&state.settings, &entry) { + let mut job = new_job(&self.inner, &state.settings, source, entry); + job.status = DownloadStatus::Completed; + job.progress = 100; + job.file_path = Some(path.to_string_lossy().into_owned()); + job.finished_at = Some(now_secs()); + return Ok(job); + } + } + let job = new_job( + &self.inner, + &state.settings, + source, + CollectionEntry { + video_id: video_id.clone(), + title: title.clone(), + artists: artists.clone(), + thumbnail: thumbnail.clone(), + collection: None, + collection_kind: None, + }, + ); + state.jobs.push(job.clone()); + if let Err(error) = self.inner.persist_locked(&state) { + state.jobs.pop(); + return Err(format!("Could not save the download queue: {error}")); + } + job + }; + self.inner.pump(); + Ok(job) + } + + /// Queue every downloadable track of a collection (playlist/album) the caller enumerated + /// server-side. Smart dedup happens here, once, over a single folder snapshot: a track whose + /// audio already sits in the download folder — under this id, a collision suffix, or the same + /// normalized title under a different upload — is reported `alreadyDownloaded` with the path + /// it found, and a track already queued or downloading is folded into `queued`. The caller + /// gets back what actually entered the queue so a toast can say "12 added · 3 already on + /// disk · 5 unavailable" instead of a silent partial write. + pub fn enqueue_collection( + &self, + entries: Vec, + ) -> Result { + if entries.is_empty() { + return Err("This collection has no tracks to download.".into()); + } + if entries.len() > MAX_ACTIVE_QUEUED { + return Err(format!( + "A collection can add at most {MAX_ACTIVE_QUEUED} tracks at once." + )); + } + let settings = self.inner.state.lock().settings.clone(); + let index = FolderIndex::build(Path::new(&settings.path)); + let mut added = 0usize; + let mut already = 0usize; + let mut skipped = 0usize; + let mut already_paths: Vec = Vec::new(); + { + let mut state = self.inner.state.lock(); + ensure_running(&state)?; + // A batch either fits or reports the same full-queue condition a single enqueue + // would, rather than filling the queue halfway: room counts running workers and + // already-queued rows together. + let queued = + state.jobs.iter().filter(|job| job.status == DownloadStatus::Queued).count(); + let room = MAX_ACTIVE_QUEUED.saturating_sub(state.running.len() + queued); + let mut batch_ids: HashSet<&str> = HashSet::new(); + let mut created: Vec = Vec::new(); + for entry in &entries { + let Ok(source) = classify(&entry.video_id) else { + skipped += 1; + continue; + }; + if entry.video_id.len() > 80 + || entry.title.len() > 512 + || entry.artists.len() > 512 + || entry.thumbnail.len() > 4096 + || entry.title.trim().is_empty() + { + skipped += 1; + continue; + } + // Dedupe against the queue: active or completed-with-file jobs already cover it, + // and the same id may appear twice inside one collection (a playlist with the + // song saved twice). + let in_queue = state.jobs.iter().any(|job| { + job.video_id == entry.video_id + && (job.status.is_active() + || (job.status == DownloadStatus::Completed && file_present(job))) + }); + if in_queue || !batch_ids.insert(entry.video_id.as_str()) { + already += 1; + continue; + } + if let Some(path) = index.find(&settings, entry) { + already += 1; + if already_paths.len() < 6 { + already_paths.push(path.to_string_lossy().into_owned()); + } + continue; + } + if added >= room { + skipped += 1; + continue; + } + let job = new_job(&self.inner, &settings, source, entry.clone()); + created.push(job.id.clone()); + state.jobs.push(job); + added += 1; + } + if added > 0 { + if let Err(error) = self.inner.persist_locked(&state) { + // Drop exactly the rows this batch added on a failed write: a partial queue + // the client believes is complete is worse than an error it can retry. + state.jobs.retain(|job| !created.contains(&job.id)); + return Err(format!("Could not save the download queue: {error}")); + } + } + } + if added > 0 { + self.inner.pump(); + } + Ok(json!({ + "added": added, + "alreadyDownloaded": already, + "skipped": skipped, + "alreadyPaths": already_paths, + })) + } + + pub fn cancel(&self, id: &str) -> Result<(), String> { + let result = { + let mut state = self.inner.state.lock(); + let job = state + .jobs + .iter_mut() + .find(|job| job.id == id) + .ok_or("That download is no longer in the list.")?; + if !job.status.is_active() { + return Ok(()); + } + job.status = DownloadStatus::Cancelled; + job.finished_at = Some(now_secs()); + // Do NOT release this slot: the worker owns it until its process group has exited. + if let Some(running) = state.running.get(id) { + running.cancel.notify_one(); + } + trim_history(&mut state.jobs); + self.inner.persist_locked(&state).map_err(|error| { + format!("Download cancelled, but history could not be saved: {error}") + }) + }; + self.inner.pump(); + result + } + + pub fn retry(&self, id: &str) -> Result { + let job = { + let mut state = self.inner.state.lock(); + ensure_running(&state)?; + ensure_capacity(&state)?; + let index = state + .jobs + .iter() + .position(|job| job.id == id) + .ok_or("That download is no longer in the list.")?; + let old = state.jobs[index].clone(); + if !matches!(old.status, DownloadStatus::Failed | DownloadStatus::Cancelled) { + return Err("Only failed or cancelled downloads can be retried.".into()); + } + if let Some(other) = state.jobs.iter().find(|job| { + job.id != id + && job.video_id == old.video_id + && (job.status.is_active() + || (job.status == DownloadStatus::Completed && file_present(job))) + }) { + return Ok(other.clone()); + } + let mut job = old.clone(); + // A new identity isolates this attempt from a cancelled worker still being reaped. + job.id = self.inner.next_id(); + job.status = DownloadStatus::Queued; + job.progress = 0; + job.error = None; + job.file_path = None; + job.created_at = now_secs(); + job.finished_at = None; + job.settings = state.settings.clone(); + job.warnings = Vec::new(); + state.jobs.remove(index); + state.jobs.push(job.clone()); + if let Err(error) = self.inner.persist_locked(&state) { + state.jobs.pop(); + state.jobs.insert(index, old); + return Err(format!("Could not save the download queue: {error}")); + } + job + }; + self.inner.pump(); + Ok(job) + } + + pub fn clear_history(&self) -> Result<(), String> { + { + let mut state = self.inner.state.lock(); + let previous = state.jobs.clone(); + state.jobs.retain(|job| job.status.is_active()); + if let Err(error) = self.inner.persist_locked(&state) { + state.jobs = previous; + return Err(format!("Could not clear download history: {error}")); + } + } + self.inner.emit(true); + Ok(()) + } + + pub fn open(&self, id: &str) -> Result<(), String> { + let path = self + .inner + .state + .lock() + .jobs + .iter() + .find(|job| job.id == id) + .and_then(|job| job.file_path.clone()) + .ok_or("That download has not finished yet.")?; + let path = Path::new(&path); + if !path.is_file() { + return Err("The downloaded file has moved or been deleted.".into()); + } + open_path(path.parent().ok_or("The download has no parent folder.")?) + } + + pub fn open_folder(&self) -> Result<(), String> { + let path = self.inner.state.lock().settings.path.clone(); + std::fs::create_dir_all(&path).map_err(|error| error.to_string())?; + open_path(Path::new(&path)) + } + + pub async fn shutdown(&self) { + { + let mut state = self.inner.state.lock(); + state.stopping = true; + recover_interrupted(&mut state.jobs); + trim_history(&mut state.jobs); + for running in state.running.values() { + running.cancel.notify_one(); + } + if let Err(error) = self.inner.persist_locked(&state) { + tracing::error!(%error, "could not save interrupted download history"); + } + } + self.inner.emit(true); + loop { + let finished = self.inner.finished.notified(); + tokio::pin!(finished); + finished.as_mut().enable(); + if self.inner.state.lock().running.is_empty() { + break; + } + finished.await; + } + } +} + +/// One fresh, queued job for an entry, freezing the current settings and minting an id. +fn new_job( + inner: &Inner, + settings: &DownloadSettings, + source: Source, + entry: CollectionEntry, +) -> DownloadJob { + let collection = + entry.collection.as_deref().map(|label| truncate_chars(label.trim(), MAX_COMPONENT_BYTES)); + DownloadJob { + id: inner.next_id(), + video_id: entry.video_id, + title: entry.title, + artists: entry.artists, + thumbnail: entry.thumbnail, + status: DownloadStatus::Queued, + progress: 0, + file_path: None, + error: None, + source: source.label().into(), + collection: collection.filter(|label| !label.is_empty()), + collection_kind: entry + .collection_kind + .as_deref() + .filter(|kind| matches!(*kind, "album" | "playlist")) + .map(str::to_owned), + created_at: now_secs(), + finished_at: None, + settings: settings.clone(), + warnings: Vec::new(), + } +} + +fn ensure_running(state: &State) -> Result<(), String> { + if state.stopping { + Err("Ryotunes is shutting down.".into()) + } else { + Ok(()) + } +} + +fn ensure_capacity(state: &State) -> Result<(), String> { + let queued = state.jobs.iter().filter(|job| job.status == DownloadStatus::Queued).count(); + if queued + state.running.len() >= MAX_ACTIVE_QUEUED { + Err(format!( + "The download queue is full ({MAX_ACTIVE_QUEUED} tracks). Let some finish first." + )) + } else { + Ok(()) + } +} + +impl Inner { + fn pump(self: &Arc) { + { + let mut state = self.state.lock(); + while !state.stopping + && state.running.len() < state.settings.workers.clamp(1, 4) as usize + { + let Some(index) = + state.jobs.iter().position(|job| job.status == DownloadStatus::Queued) + else { + break; + }; + state.jobs[index].status = DownloadStatus::Downloading; + let job = state.jobs[index].clone(); + let cancel = Arc::new(Notify::new()); + state.running.insert(job.id.clone(), Running { cancel: cancel.clone() }); + let inner = self.clone(); + tokio::spawn(async move { + let _slot = WorkerSlot { inner: inner.clone(), id: job.id.clone() }; + inner.run_job(job, cancel).await; + }); + } + // Lifecycle reconciliation stays under the same lock as queue mutations. A stale + // "idle" notification cannot race a newer enqueue and stop the daemon prematurely. + self.lifecycle.downloads_busy_changed( + !state.running.is_empty() || state.jobs.iter().any(|job| job.status.is_active()), + ); + } + self.emit(true); + } + + async fn run_job(self: &Arc, job: DownloadJob, cancel: Arc) { + let result = tokio::select! { + biased; + _ = cancel.notified() => return, + result = tokio::time::timeout(JOB_TIMEOUT, self.resolve_target(&job)) => { + match result { + Ok(result) => result, + Err(_) => Err("The download source took too long to respond.".into()), + } + } + }; + let target = match result { + Ok(target) => target, + Err(error) => { + self.finish(&job.id, Err(error)); + return; + } + }; + if !self.is_current(&job.id) { + return; + } + let staging = match create_staging(&job.settings.path, &job.id) { + Ok(path) => path, + Err(error) => { + self.finish(&job.id, Err(error)); + return; + } + }; + let result = self.run_downloader(&job, &target, &staging, &cancel).await; + // After the downloader succeeds, embed artwork/lyrics and write local companion files into + // staging. The reserved slot stays held across this bounded step; a job cancelled meanwhile + // is caught by the publication gate below and never reaches the user's folder. + let mut warnings: Vec = Vec::new(); + let mut sidecars: Vec = Vec::new(); + if result.is_ok() && job.settings.embed_metadata { + if let (Some(app), true) = (self.app.as_deref(), self.is_current(&job.id)) { + if let Ok(audio) = find_output_file(&staging) { + let enrichment = crate::download_media::enrich(app, &audio, &job).await; + warnings = enrichment.warnings; + sidecars = enrichment.sidecars; + } + } + } + // Publication and cancellation are serialized; a cancelled attempt never publishes a file. + { + let mut state = self.state.lock(); + if let Some(current) = state + .jobs + .iter_mut() + .find(|entry| entry.id == job.id && entry.status == DownloadStatus::Downloading) + { + let result = + result.and_then(|()| publish(&staging, &job, &sidecars, &mut warnings)); + apply_result(current, result); + if current.status == DownloadStatus::Completed { + current.warnings = warnings; + } + trim_history(&mut state.jobs); + self.save_transition(&mut state, &job.id); + } + } + let _ = std::fs::remove_dir_all(staging); + self.emit(true); + } + + async fn resolve_target(&self, job: &DownloadJob) -> Result { + match classify(&job.video_id)? { + Source::YouTube => Ok(format!("https://www.youtube.com/watch?v={}", job.video_id)), + Source::Spotify => { + Ok(format!("ytsearch1:{} {} official audio", job.artists.trim(), job.title.trim())) + } + Source::SoundCloud => { + let id = sc_track_id(&job.video_id).ok_or("Invalid SoundCloud track id.")?; + let track = + self.soundcloud.track(id).await.map_err(|error| { + format!("Could not look up the SoundCloud track: {error}") + })?; + let url = url::Url::parse(&track.permalink_url) + .map_err(|_| "SoundCloud returned an invalid track link.")?; + if url.scheme() != "https" + || !matches!(url.host_str(), Some("soundcloud.com" | "www.soundcloud.com")) + { + return Err("SoundCloud returned an unsupported track link.".into()); + } + Ok(url.into()) + } + } + } + + async fn run_downloader( + self: &Arc, + job: &DownloadJob, + target: &str, + staging: &Path, + cancel: &Notify, + ) -> Result<(), String> { + // Holding the lock across spawn closes the cancel-before-register race. No await occurs + // under this lock, and the slot was reserved before we got here. + let mut child = { + let state = self.state.lock(); + if state.stopping + || !state + .jobs + .iter() + .any(|entry| entry.id == job.id && entry.status == DownloadStatus::Downloading) + { + return Err("Download cancelled.".into()); + } + build_command(target, staging, &job.settings).spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "Install yt-dlp and FFmpeg to download music.".into() + } else { + format!("Could not start yt-dlp: {error}") + } + })? + }; + let mut group = ProcessGroup(child.id().map(|pid| pid as i32)); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let streams = async { + tokio::join!( + read_stream(self, &job.id, stdout, false), + read_stream(self, &job.id, stderr, true) + ) + .1 + }; + tokio::pin!(streams); + let result = tokio::select! { + biased; + _ = cancel.notified() => Err("Download cancelled.".to_string()), + _ = tokio::time::sleep(JOB_TIMEOUT) => Err("The download exceeded 30 minutes. Retry when the connection improves.".into()), + result = async { tokio::join!(child.wait(), &mut streams) } => Ok(result), + }; + match result { + Ok((status, tail)) => { + group.0 = None; + match status { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(error_message(&tail, status.code())), + Err(error) => Err(format!("Could not wait for yt-dlp: {error}")), + } + } + Err(error) => { + group.kill(); + let _ = child.wait().await; + let _ = tokio::time::timeout(Duration::from_secs(2), &mut streams).await; + Err(error) + } + } + } + + fn is_current(&self, id: &str) -> bool { + let state = self.state.lock(); + !state.stopping + && state + .jobs + .iter() + .any(|job| job.id == id && job.status == DownloadStatus::Downloading) + } + + fn finish(&self, id: &str, result: Result) { + { + let mut state = self.state.lock(); + if let Some(job) = state + .jobs + .iter_mut() + .find(|job| job.id == id && job.status == DownloadStatus::Downloading) + { + apply_result(job, result); + trim_history(&mut state.jobs); + self.save_transition(&mut state, id); + } + } + self.emit(true); + } + + fn save_transition(&self, state: &mut State, id: &str) { + if let Err(error) = self.persist_locked(state) { + tracing::error!(%error, "could not save download history"); + if let Some(job) = state.jobs.iter_mut().find(|job| job.id == id) { + job.status = DownloadStatus::Failed; + job.error = Some(format!( + "{}History could not be saved: {error}", + if job.file_path.is_some() { "Audio saved to your folder. " } else { "" } + )); + } + } + } + + fn progress(&self, id: &str, progress: u32) { + let changed = { + let mut state = self.state.lock(); + if let Some(job) = state + .jobs + .iter_mut() + .find(|job| job.id == id && job.status == DownloadStatus::Downloading) + { + let next = progress.min(99); + let changed = job.progress != next; + job.progress = next; + changed + } else { + false + } + }; + if changed { + self.emit(false); + } + } + + fn snapshot(&self) -> Value { + let state = self.state.lock(); + json!({ + "jobs": state.jobs.iter().rev().collect::>(), + "activeCount": state.running.len(), + "queuedCount": state.jobs.iter().filter(|job| job.status == DownloadStatus::Queued).count(), + }) + } + + fn emit(&self, force: bool) { + let mut last = self.last_emit.lock(); + if !force && last.elapsed() < EVENT_THROTTLE { + return; + } + *last = Instant::now(); + self.sink.emit("downloads-changed", self.snapshot()); + } + + // Every caller holds state, so writes cannot race or persist an older snapshot over a newer + // one. The final rename is atomic; fsync both file and directory before reporting a saved edit. + fn persist_locked(&self, state: &State) -> std::io::Result<()> { + std::fs::create_dir_all(&self.data_dir)?; + let temporary = self.data_dir.join("downloads.json.tmp"); + let result = (|| { + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + serde_json::to_writer( + &mut file, + &PersistedRef { settings: &state.settings, jobs: &state.jobs }, + )?; + file.flush()?; + file.sync_all()?; + std::fs::rename(&temporary, self.data_dir.join(STATE_FILE))?; + std::fs::File::open(&self.data_dir)?.sync_all() + })(); + let _ = std::fs::remove_file(&temporary); + result + } + + fn next_id(&self) -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + format!("dl-{nanos}-{}", self.id_seq.fetch_add(1, Ordering::Relaxed)) + } +} + +// Owns a reserved slot through every return/unwind. Cancellation never releases the slot early. +struct WorkerSlot { + inner: Arc, + id: String, +} +impl Drop for WorkerSlot { + fn drop(&mut self) { + { + let mut state = self.inner.state.lock(); + state.running.remove(&self.id); + if let Some(job) = state + .jobs + .iter_mut() + .find(|job| job.id == self.id && job.status == DownloadStatus::Downloading) + { + apply_result( + job, + Err("The download worker stopped unexpectedly. Retry this download.".into()), + ); + self.inner.save_transition(&mut state, &self.id); + } + } + self.inner.pump(); + self.inner.finished.notify_waiters(); + } +} + +struct ProcessGroup(Option); +impl ProcessGroup { + fn kill(&mut self) { + if let Some(pid) = self.0.take() { + // Safe: this process group was created for our own live child, never a supplied pid. + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + } +} +impl Drop for ProcessGroup { + fn drop(&mut self) { + self.kill(); + } +} + +fn apply_result(job: &mut DownloadJob, result: Result) { + job.finished_at = Some(now_secs()); + match result { + Ok(path) => { + job.status = DownloadStatus::Completed; + job.file_path = Some(path); + job.progress = 100; + } + Err(error) => { + job.status = DownloadStatus::Failed; + job.error = Some(truncate_chars(&error, MAX_ERROR_LEN)); + // A worker failure never passes through the RPC chokepoint (it is async), so log it + // here: the diagnostics CaptureLayer picks warn+ tracing up into the same record. + tracing::error!(job = %job.id, video_id = %job.video_id, error, "download failed"); + } + } +} + +fn build_command(target: &str, staging: &Path, settings: &DownloadSettings) -> Command { + let template = format!("{}/audio.%(ext)s", staging.to_string_lossy().replace('%', "%%")); + let mut command = Command::new("yt-dlp"); + command + .args([ + "--ignore-config", + "--no-plugin-dirs", + "--no-playlist", + "--no-exec", + "--no-continue", + "--newline", + "--no-color", + "--progress", + "--progress-delta", + "0.4", + "--socket-timeout", + "20", + "--retries", + "2", + "--fragment-retries", + "2", + "--concurrent-fragments", + "1", + "--postprocessor-args", + "ffmpeg:-threads 1", + "-f", + "bestaudio/best", + "-x", + ]) + .arg("-o") + .arg(template); + match settings.format { + DownloadFormat::Original => { + command.args(["--audio-format", "best"]); + } + DownloadFormat::Mp3 => { + command.args(["--audio-format", "mp3", "--audio-quality", "320K"]); + } + DownloadFormat::Opus => { + command.args(["--audio-format", "opus", "--audio-quality", "160K"]); + } + } + if settings.embed_metadata { + command.arg("--embed-metadata"); + } + command + .arg("--") + .arg(target) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.process_group(0).kill_on_drop(true); + // Only async-signal-safe operations execute between fork and exec. + unsafe { + command.pre_exec(|| { + libc::nice(10); + Ok(()) + }); + } + command +} + +// Read fixed-size chunks instead of lines(): an extractor must not allocate an unbounded line. +async fn read_stream( + inner: &Inner, + id: &str, + mut reader: R, + collect: bool, +) -> String { + let mut buffer = [0u8; 4096]; + let mut line = Vec::with_capacity(4096); + let mut tail = Vec::with_capacity(STDERR_TAIL_BYTES); + while let Ok(count) = reader.read(&mut buffer).await { + if count == 0 { + break; + } + if collect { + if tail.len() + count > STDERR_TAIL_BYTES { + tail.drain(..tail.len() + count - STDERR_TAIL_BYTES); + } + tail.extend_from_slice(&buffer[..count]); + } + for byte in &buffer[..count] { + if *byte == b'\n' || *byte == b'\r' { + if let Some(progress) = parse_progress(&String::from_utf8_lossy(&line)) { + inner.progress(id, progress); + } + line.clear(); + } else if line.len() < 4096 { + line.push(*byte); + } + } + } + String::from_utf8_lossy(&tail).into_owned() +} + +fn create_staging(folder: &str, id: &str) -> Result { + use std::os::unix::fs::DirBuilderExt; + let root = Path::new(folder).join(STAGING_DIR); + std::fs::create_dir_all(folder).map_err(|error| error.to_string())?; + match std::fs::DirBuilder::new().mode(0o700).create(&root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if !std::fs::symlink_metadata(&root).map_err(|error| error.to_string())?.is_dir() { + return Err("The temporary download folder is not a regular directory.".into()); + } + } + Err(error) => { + return Err(format!("Could not create the temporary download folder: {error}")) + } + } + let path = root.join(sanitize_component(id)); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|error| format!("Could not prepare this download: {error}"))?; + Ok(path) +} + +fn publish( + staging: &Path, + job: &DownloadJob, + sidecars: &[PathBuf], + warnings: &mut Vec, +) -> Result { + let produced = find_output_file(staging)?; + let extension = produced + .extension() + .and_then(|value| value.to_str()) + .ok_or("The download has no audio extension.")?; + let path = compute_final_path( + &job.settings, + &job.title, + &job.artists, + &job.video_id, + extension, + job.collection.as_deref(), + ); + let parent = path.parent().ok_or("The download has no destination folder.")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let root = Path::new(&job.settings.path).canonicalize().map_err(|error| error.to_string())?; + if !parent.canonicalize().map_err(|error| error.to_string())?.starts_with(&root) { + return Err("The artist folder points outside your download folder.".into()); + } + std::fs::File::open(&produced) + .and_then(|file| file.sync_all()) + .map_err(|error| error.to_string())?; + let stem = + path.file_stem().and_then(|value| value.to_str()).ok_or("Invalid download filename.")?; + // Companion artwork/lyrics validated down to regular, non-empty files inside this staging + // directory with a supported extension; anything else is dropped rather than published. + let companions = collect_sidecars(staging, sidecars); + // Flush each validated companion to disk before publishing it, matching the audio's durability + // above. Best-effort: a flush failure must not block saving the file. + for (_, source) in &companions { + let _ = std::fs::File::open(source).and_then(|file| file.sync_all()); + } + for suffix in 0..1000 { + let destination = if suffix == 0 { + path.clone() + } else { + parent.join(format!("{stem} ({suffix}).{extension}")) + }; + // hard_link is an atomic no-replace publication on the destination filesystem. Staging is + // under the same root. Never fall back to copy/rename, both of which can overwrite files. + match std::fs::hard_link(&produced, &destination) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Could not save the audio file: {error}")), + } + // Each companion lands under the audio's exact stem and suffix, never overwriting a file. + // A companion collision means this suffix is not clean for the whole set: roll back only + // the links this attempt created and try the next one so audio and sidecars stay together. + // A non-collision error on a companion is best-effort — the audio still lands without it. + let mut created = vec![destination.clone()]; + let mut attempt_warnings: Vec = Vec::new(); + let mut collision = false; + for (ext, source) in &companions { + let companion = if suffix == 0 { + parent.join(format!("{stem}.{ext}")) + } else { + parent.join(format!("{stem} ({suffix}).{ext}")) + }; + match std::fs::hard_link(source, &companion) { + Ok(()) => created.push(companion), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + collision = true; + break; + } + // A non-collision failure (the filesystem rejected the link) never fails the audio, + // but the user must see the companion was skipped rather than lose it silently. + Err(error) => attempt_warnings + .push(format!("Couldn't save the {} file: {error}", companion_label(ext))), + } + } + if collision { + for link in &created { + let _ = std::fs::remove_file(link); + } + continue; + } + warnings.append(&mut attempt_warnings); + return Ok(destination.to_string_lossy().into_owned()); + } + Err("Too many files share this track's filename. Choose another download folder.".into()) +} + +/// Validate the enrichment's companion files: each must be a regular, non-empty file that lives +/// directly in this job's staging directory and carries a supported sidecar extension. Duplicates +/// of an extension and anything failing a check are dropped, so publication never links a symlink, +/// a directory, an empty file, or a path outside staging. +fn collect_sidecars(staging: &Path, sidecars: &[PathBuf]) -> Vec<(String, PathBuf)> { + let mut out: Vec<(String, PathBuf)> = Vec::new(); + for path in sidecars { + if path.parent() != Some(staging) { + continue; + } + let ext = match path.extension().and_then(|value| value.to_str()) { + Some(value) => value.to_ascii_lowercase(), + None => continue, + }; + if !matches!(ext.as_str(), "jpg" | "png" | "lrc" | "txt") { + continue; + } + if out.iter().any(|(seen, _)| seen == &ext) { + continue; + } + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_file() && meta.len() > 0 => out.push((ext, path.clone())), + _ => continue, + } + } + out +} + +/// A friendly noun for a companion extension, used in the non-fatal warning shown in history. +fn companion_label(ext: &str) -> &'static str { + match ext { + "jpg" | "png" => "cover art", + "lrc" => "synced lyrics", + "txt" => "lyrics", + _ => "companion", + } +} + +fn find_output_file(directory: &Path) -> Result { + let mut output = None; + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + let extension = path.extension().and_then(|value| value.to_str()).unwrap_or_default(); + if metadata.is_file() + && metadata.len() > 0 + && path.file_stem().and_then(|value| value.to_str()) == Some("audio") + && matches!( + extension, + "mp3" | "m4a" | "opus" | "ogg" | "aac" | "flac" | "wav" | "webm" | "mp4" + ) + { + if output.is_some() { + return Err("The downloader produced ambiguous audio output.".into()); + } + output = Some(path); + } + } + output.ok_or_else(|| "The download produced no finished audio file.".into()) +} + +fn file_present(job: &DownloadJob) -> bool { + job.file_path + .as_ref() + .and_then(|path| std::fs::metadata(path).ok()) + .is_some_and(|metadata| metadata.is_file() && metadata.len() > 0) +} + +fn open_path(path: &Path) -> Result<(), String> { + let mut child = Command::new("xdg-open") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("Could not open the folder: {error}"))?; + // A folder handler may remain open for hours. Reap asynchronously so an open file manager + // cannot hold Tokio's blocking pool (and therefore daemon shutdown) alive. + tokio::spawn(async move { + let _ = child.wait().await; + }); + Ok(()) +} + +fn sanitize_settings(mut settings: DownloadSettings) -> Result { + if !(1..=4).contains(&settings.workers) { + return Err("Choose between 1 and 4 download workers.".into()); + } + if settings.path.len() > 4096 || settings.path.contains('\0') { + return Err("The download folder path is invalid.".into()); + } + let path = if settings.path.trim().is_empty() { + default_music_dir() + } else { + PathBuf::from(expand_tilde(settings.path.trim())) + }; + if !path.is_absolute() || path.parent().is_none() { + return Err("Choose an absolute download folder, not the filesystem root.".into()); + } + std::fs::create_dir_all(&path) + .map_err(|error| format!("Could not create the download folder: {error}"))?; + let path = path.canonicalize().map_err(|error| error.to_string())?; + if !path.is_dir() { + return Err("The download destination is not a folder.".into()); + } + settings.path = path.to_string_lossy().into_owned(); + Ok(settings) +} + +#[derive(Serialize)] +struct PersistedRef<'a> { + settings: &'a DownloadSettings, + jobs: &'a [DownloadJob], +} + +#[derive(Serialize, Deserialize)] +struct Persisted { + settings: DownloadSettings, + jobs: Vec, +} + +fn load(directory: &Path) -> anyhow::Result<(DownloadSettings, Vec)> { + let path = directory.join(STATE_FILE); + // An interrupted atomic write is never authoritative. + let _ = std::fs::remove_file(directory.join("downloads.json.tmp")); + let file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((DownloadSettings::defaults(), Vec::new())) + } + Err(error) => return Err(error.into()), + }; + let mut bytes = Vec::new(); + file.take(8 * 1024 * 1024 + 1).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= 8 * 1024 * 1024, "download history exceeds its safe size limit"); + let mut stored: Persisted = serde_json::from_slice(&bytes)?; + stored.settings.workers = stored.settings.workers.clamp(1, 4); + anyhow::ensure!( + Path::new(&stored.settings.path).is_absolute(), + "saved download folder is not absolute" + ); + anyhow::ensure!( + stored.jobs.len() <= MAX_ACTIVE_QUEUED + MAX_HISTORY, + "too many saved download records" + ); + for job in &mut stored.jobs { + job.settings.workers = job.settings.workers.clamp(1, 4); + } + Ok((stored.settings, stored.jobs)) +} + +fn recover_interrupted(jobs: &mut [DownloadJob]) -> bool { + let mut changed = false; + for job in jobs.iter_mut().filter(|job| job.status.is_active()) { + apply_result(job, Err(INTERRUPTED_MSG.into())); + changed = true; + } + changed +} + +fn trim_history(jobs: &mut Vec) { + let mut excess = + jobs.iter().filter(|job| !job.status.is_active()).count().saturating_sub(MAX_HISTORY); + jobs.retain(|job| { + if excess > 0 && !job.status.is_active() { + excess -= 1; + false + } else { + true + } + }); +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")) +} + +fn parse_progress(line: &str) -> Option { + if !line.contains("[download]") { + return None; + } + for token in line.split_whitespace() { + if let Some(num) = token.strip_suffix('%') { + if let Ok(pct) = num.parse::() { + if pct.is_finite() { + return Some(pct.clamp(0.0, 100.0).round() as u32); + } + } + } + } + None +} + +/// Turn a bounded stderr tail into a one-line error, preferring yt-dlp's own `ERROR:` line. +fn error_message(tail: &str, code: Option) -> String { + let mut last_error = None; + let mut last_nonempty = None; + for line in tail.lines() { + let l = line.trim(); + if l.is_empty() { + continue; + } + last_nonempty = Some(l); + if l.contains("ERROR") { + last_error = Some(l); + } + } + let chosen = last_error.or(last_nonempty); + match chosen { + Some(l) => truncate_chars(l, MAX_ERROR_LEN), + None => match code { + Some(c) => format!("The download failed (exit code {c})."), + None => "The download was terminated.".into(), + }, + } +} + +/// The destination for a finished download. A track batch-added from an album/playlist lands in +/// a folder named after its collection (the collection *is* the grouping, so the artist +/// subfolder is skipped for it); a single track follows the plain artist settings. Filenames +/// are unchanged either way, which keeps `FolderIndex::find` (stem-keyed, walks recursively) +/// able to dedup files wherever they sit. +fn compute_final_path( + settings: &DownloadSettings, + title: &str, + artists: &str, + video_id: &str, + ext: &str, + collection: Option<&str>, +) -> PathBuf { + let mut dir = PathBuf::from(&settings.path); + match collection.map(str::trim).filter(|c| !c.is_empty()) { + Some(label) => dir = dir.join(sanitize_component(label)), + None if settings.organize_by_artist => { + dir = dir.join(sanitize_component(primary_artist(artists))); + } + None => {} + } + let base = if settings.organize_by_artist || artists.trim().is_empty() { + title.to_string() + } else { + format!("{} - {}", artists.trim(), title.trim()) + }; + let name = sanitize_component(&base); + let id_tag = sanitize_component(video_id); + dir.join(format!("{name} [{id_tag}].{ext}")) +} + +/// The first credited artist, for the per-artist folder. +fn primary_artist(artists: &str) -> &str { + let cut = artists + .find(|c: char| matches!(c, ',' | '&' | '•' | ';')) + .or_else(|| artists.find(" feat")) + .or_else(|| artists.find(" ft.")) + .unwrap_or(artists.len()); + let first = artists[..cut].trim(); + if first.is_empty() { + "Unknown Artist" + } else { + first + } +} + +/// Make one path component safe: drop control chars, replace path separators and other reserved +/// characters with `_`, strip leading/trailing dots and spaces (so no `.`/`..`/hidden component), +/// and bound the length. Falls back to `download` if nothing usable remains. +fn sanitize_component(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut last_space = false; + for ch in input.chars() { + if ch.is_control() { + continue; + } + let mapped = match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_', + c => c, + }; + // Collapse runs of whitespace to single spaces. + if mapped.is_whitespace() { + if last_space { + continue; + } + last_space = true; + out.push(' '); + } else { + last_space = false; + out.push(mapped); + } + } + let trimmed = out.trim().trim_matches('.').trim(); + let bounded = truncate_chars(trimmed, MAX_COMPONENT_BYTES); + let bounded = bounded.trim().trim_matches('.').trim(); + if bounded.is_empty() { + "download".to_string() + } else { + bounded.to_string() + } +} + +fn expand_tilde(raw: &str) -> String { + if raw == "~" { + home_dir().to_string_lossy().into_owned() + } else if let Some(rest) = raw.strip_prefix("~/") { + home_dir().join(rest).to_string_lossy().into_owned() + } else { + raw.to_string() + } +} + +/// The default download folder: the XDG Music directory's `Ryotunes/`, falling back to +/// `$HOME/Music/Ryotunes`. +fn default_music_dir() -> PathBuf { + music_root().join("Ryotunes") +} + +/// The user's Music directory: `$XDG_MUSIC_DIR` if absolute, then the `user-dirs.dirs` +/// `XDG_MUSIC_DIR` entry, else `$HOME/Music`. +fn music_root() -> PathBuf { + if let Some(dir) = std::env::var_os("XDG_MUSIC_DIR") { + let p = PathBuf::from(dir); + if p.is_absolute() { + return p; + } + } + if let Some(p) = xdg_user_music_dir() { + return p; + } + home_dir().join("Music") +} + +/// Parse `XDG_MUSIC_DIR="$HOME/Music"` out of `~/.config/user-dirs.dirs` (or `$XDG_CONFIG_HOME`). +fn xdg_user_music_dir() -> Option { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home_dir().join(".config")); + let text = std::fs::read_to_string(config_home.join("user-dirs.dirs")).ok()?; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some(rest) = line.strip_prefix("XDG_MUSIC_DIR=") else { continue }; + let value = rest.trim().trim_matches('"'); + let expanded = if let Some(tail) = value.strip_prefix("$HOME/") { + home_dir().join(tail) + } else if value == "$HOME" { + home_dir() + } else { + PathBuf::from(value) + }; + if expanded.is_absolute() { + return Some(expanded); + } + } + None +} + +fn truncate_chars(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut out = String::with_capacity(max); + for ch in s.chars() { + if out.len() + ch.len_utf8() > max { + break; + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + // Tests share one process and run on parallel threads: pid+nanos alone can collide + // when two threads read the same coarse clock tick, failing create_dir on the + // loser's path. A process-local counter guarantees uniqueness. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "ryotunes-download-test-{}-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(), + SEQ.fetch_add(1, Ordering::Relaxed), + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + struct Sink; + impl EventSink for Sink { + fn emit(&self, _: &'static str, _: Value) {} + } + + fn manager(directory: &Path) -> Arc { + let (quit, _) = tokio::sync::mpsc::unbounded_channel(); + Downloads::new( + Arc::new(Sink), + Arc::new(SoundCloud::new(directory.join("soundcloud"))), + directory.join("state"), + Lifecycle::new(quit), + None, + ) + .unwrap() + } + fn settings(directory: &Path, workers: u32) -> DownloadSettings { + DownloadSettings { + path: directory.to_string_lossy().into_owned(), + workers, + format: DownloadFormat::Original, + organize_by_artist: false, + embed_metadata: true, + } + } + fn job(directory: &Path) -> DownloadJob { + DownloadJob { + id: "publication".into(), + video_id: "jNQXAC9IVRw".into(), + title: "../../a %(title)s".into(), + artists: "../artist".into(), + thumbnail: String::new(), + status: DownloadStatus::Downloading, + progress: 0, + file_path: None, + error: None, + source: "YouTube".into(), + collection: None, + collection_kind: None, + created_at: 1, + finished_at: None, + settings: settings(directory, 1), + warnings: Vec::new(), + } + } + + fn entry(video_id: &str, title: &str, artists: &str) -> CollectionEntry { + CollectionEntry { + video_id: video_id.into(), + title: title.into(), + artists: artists.into(), + thumbnail: String::new(), + collection: None, + collection_kind: None, + } + } + + #[test] + fn folder_index_matches_exact_names_and_normalized_titles() { + let directory = Temp::new(); + std::fs::write(directory.0.join("Artist - Song [AAAAAAAAAAA].opus"), b"x").unwrap(); + std::fs::write(directory.0.join(format!("{STAGING_DIR}-whatever.opus")), b"partial") + .unwrap(); + let settings = settings(&directory.0, 1); + let index = FolderIndex::build(&directory.0); + + // Exact id match. + let exact = entry("AAAAAAAAAAA", "Song", "Artist"); + assert!(index.find(&settings, &exact).is_some()); + // A reupload of the same recording: different id, same normalized name — the "smart" + // half of the match. + let reupload = entry("BBBBBBBBBBB", "Song", "Artist"); + assert!(index.find(&settings, &reupload).is_some()); + // A different song, and the staging partial, never match. + assert!(index.find(&settings, &entry("BBBBBBBBBBB", "Other", "Artist")).is_none()); + assert!( + index.find(&settings, &entry("whatever", "-whatever", "")).is_none(), + "the incomplete staging dir must not read as a saved file" + ); + } + + #[tokio::test] + async fn single_enqueue_reports_a_file_already_on_disk() { + let directory = Temp::new(); + let music = directory.0.join("music"); + std::fs::create_dir(&music).unwrap(); + let manager = manager(&directory.0); + manager.set_settings(settings(&music, 1)).unwrap(); + std::fs::write(music.join("Daftpunk - Around the World [AAAAAAAAAAA].opus"), b"saved") + .unwrap(); + // Same recording, different upload id: dedup must find it and NOT queue a job. + let job = manager + .enqueue( + "ZZZZZZZZZZZ".into(), + "Around the World".into(), + "Daftpunk".into(), + String::new(), + ) + .unwrap(); + assert_eq!(job.status, DownloadStatus::Completed); + assert!(job.file_path.unwrap().ends_with("[AAAAAAAAAAA].opus")); + assert!(manager.snapshot()["jobs"].as_array().unwrap().is_empty()); + manager.shutdown().await; + } + + #[tokio::test] + async fn collection_batch_dedupes_disk_queue_and_duplicates() { + let directory = Temp::new(); + let music = directory.0.join("music"); + std::fs::create_dir(&music).unwrap(); + let manager = manager(&directory.0); + manager.set_settings(settings(&music, 1)).unwrap(); + std::fs::write(music.join("One - Alpha [AAAAAAAAAAA].opus"), b"saved").unwrap(); + // One already-queued (downloading) job counts as already covered, not added. + manager.enqueue("BBBBBBBBBBB".into(), "Beta".into(), "One".into(), String::new()).unwrap(); + let res = manager + .enqueue_collection(vec![ + entry("AAAAAAAAAAA", "Alpha", "One"), + entry("BBBBBBBBBBB", "Beta", "One"), + entry("CCCCCCCCCCC", "Gamma", "One"), + entry("CCCCCCCCCCC", "Gamma (reprise)", "One"), + entry("not-a-track-id!", "Local", "One"), + ]) + .unwrap(); + assert_eq!(res["added"], 1, "only the new, valid, non-duplicate track enters the queue"); + assert_eq!(res["alreadyDownloaded"], 3, "disk hit, queue hit, in-batch repeat"); + assert_eq!(res["skipped"], 1, "the unclassifiable id is skipped"); + assert!(res["alreadyPaths"][0].as_str().unwrap().ends_with("[AAAAAAAAAAA].opus")); + let ids: Vec = manager.snapshot()["jobs"] + .as_array() + .unwrap() + .iter() + .map(|job| job["videoId"].as_str().unwrap().to_owned()) + .collect(); + assert!(ids.contains(&"CCCCCCCCCCC".to_string())); + assert!(!ids.contains(&"AAAAAAAAAAA".to_string())); + } + + // The exact wire shape the QML client posts (camelCase keys). The batch endpoint once + // deserialized with snake_case field names, so every collection download died with + // "entries: missing field `video_id`" before any track was admitted. + #[test] + fn collection_entries_deserialize_from_the_camel_case_wire_shape() { + let wire = serde_json::json!([ + { + "videoId": "AAAAAAAAAAA", + "title": "Alpha", + "artists": "One", + "thumbnail": "https://example/thumb.jpg", + "collection": "Greatest Hits", + "collectionKind": "album" + }, + { + "videoId": "BBBBBBBBBBB", + "title": "Beta", + "artists": "One", + "thumbnail": "" + } + ]); + let entries: Vec = serde_json::from_value(wire).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].video_id, "AAAAAAAAAAA"); + assert_eq!(entries[0].collection.as_deref(), Some("Greatest Hits")); + assert_eq!(entries[0].collection_kind.as_deref(), Some("album")); + assert_eq!(entries[1].collection, None, "collection is optional on the wire"); + } + + #[tokio::test] + async fn batch_jobs_carry_their_collection_label_for_grouped_rendering() { + let directory = Temp::new(); + let music = directory.0.join("music"); + std::fs::create_dir(&music).unwrap(); + let manager = manager(&directory.0); + manager.set_settings(settings(&music, 1)).unwrap(); + let mut tagged = entry("AAAAAAAAAAA", "Alpha", "One"); + tagged.collection = Some(" Late Night Playlist ".into()); + tagged.collection_kind = Some("playlist".into()); + let mut untagged = entry("BBBBBBBBBBB", "Beta", "One"); + untagged.collection = Some(" ".into()); + untagged.collection_kind = Some("bogus-kind".into()); + manager.enqueue_collection(vec![tagged, untagged]).unwrap(); + let jobs = manager.snapshot()["jobs"].as_array().unwrap().clone(); + let alpha = jobs.iter().find(|j| j["videoId"] == "AAAAAAAAAAA").unwrap(); + assert_eq!(alpha["collection"], "Late Night Playlist", "label is trimmed"); + assert_eq!(alpha["collectionKind"], "playlist"); + let beta = jobs.iter().find(|j| j["videoId"] == "BBBBBBBBBBB").unwrap(); + assert!(beta["collection"].is_null(), "a blank label stores no collection"); + assert!(beta["collectionKind"].is_null(), "an unknown kind is dropped"); + manager.shutdown().await; + } + + #[test] + fn collection_tracks_land_in_a_folder_named_after_the_collection() { + let directory = Temp::new(); + let mut s = settings(&directory.0, 1); + s.organize_by_artist = true; + let album = + compute_final_path(&s, "Alpha", "One", "AAAAAAAAAAA", "mp3", Some("Late Night")); + assert_eq!( + album, + directory.0.join("Late Night").join("Alpha [AAAAAAAAAAA].mp3"), + "a collection track is filed under its collection, not its artist" + ); + let single = compute_final_path(&s, "Beta", "Two", "BBBBBBBBBBB", "mp3", None); + assert_eq!( + single, + directory.0.join("Two").join("Beta [BBBBBBBBBBB].mp3"), + "a single track still follows the artist folder" + ); + let traversal = + compute_final_path(&s, "Gamma", "Three", "CCCCCCCCCCC", "mp3", Some("../../etc")); + assert!( + traversal.starts_with(&directory.0), + "the collection folder is sanitized inside the root" + ); + } + + #[test] + fn publication_never_replaces_existing_files_or_accepts_partials() { + let directory = Temp::new(); + let staging = create_staging(directory.0.to_str().unwrap(), "publication").unwrap(); + std::fs::write(staging.join("audio.mp3.part"), b"partial").unwrap(); + assert!(publish(&staging, &job(&directory.0), &[], &mut Vec::new()).is_err()); + std::fs::write(staging.join("audio.mp3"), b"finished audio").unwrap(); + let j = job(&directory.0); + let first = compute_final_path( + &j.settings, + &j.title, + &j.artists, + &j.video_id, + "mp3", + j.collection.as_deref(), + ); + std::fs::write(&first, b"existing user music").unwrap(); + let second = publish(&staging, &job(&directory.0), &[], &mut Vec::new()).unwrap(); + assert_eq!(std::fs::read(&first).unwrap(), b"existing user music"); + assert_eq!(std::fs::read(&second).unwrap(), b"finished audio"); + assert_eq!(Path::new(&second).parent().unwrap(), directory.0); + } + + #[test] + fn symlinked_staging_and_audio_are_rejected() { + use std::os::unix::fs::symlink; + let directory = Temp::new(); + let elsewhere = Temp::new(); + symlink(&elsewhere.0, directory.0.join(STAGING_DIR)).unwrap(); + assert!(create_staging(directory.0.to_str().unwrap(), "blocked").is_err()); + std::fs::write(elsewhere.0.join("source"), b"not our output").unwrap(); + symlink(elsewhere.0.join("source"), elsewhere.0.join("audio.mp3")).unwrap(); + assert!(find_output_file(&elsewhere.0).is_err()); + } + + #[tokio::test] + async fn cancellation_keeps_slots_reserved_and_retry_cannot_oversubscribe() { + let directory = Temp::new(); + let manager = manager(&directory.0); + manager.set_settings(settings(&directory.0.join("music"), 2)).unwrap(); + let first = manager + .enqueue("jNQXAC9IVRw".into(), "one".into(), "artist".into(), "".into()) + .unwrap(); + manager.enqueue("dQw4w9WgXcQ".into(), "two".into(), "artist".into(), "".into()).unwrap(); + let third = manager + .enqueue("aqz-KE-bpKQ".into(), "three".into(), "artist".into(), "".into()) + .unwrap(); + let duplicate = manager + .enqueue("jNQXAC9IVRw".into(), "one".into(), "artist".into(), "".into()) + .unwrap(); + assert_eq!(duplicate.id, first.id); + manager.cancel(&first.id).unwrap(); + let retried = manager.retry(&first.id).unwrap(); + assert_eq!(manager.snapshot()["activeCount"], 2); + assert_eq!(manager.snapshot()["queuedCount"], 2); + assert_eq!(retried.status, DownloadStatus::Queued); + let mut next = settings(&directory.0.join("new-music"), 1); + next.format = DownloadFormat::Mp3; + manager.set_settings(next).unwrap(); + let frozen = manager.snapshot()["jobs"] + .as_array() + .unwrap() + .iter() + .find(|job| job["id"] == third.id) + .unwrap() + .clone(); + assert_eq!(frozen["settings"]["format"], "original"); + assert!(frozen["settings"]["path"].as_str().unwrap().ends_with("/music")); + manager.shutdown().await; + assert_eq!(manager.snapshot()["activeCount"], 0); + assert_eq!(manager.snapshot()["queuedCount"], 0); + assert!(manager + .enqueue("jNQXAC9IVRw".into(), "one".into(), "a".into(), "".into()) + .is_err()); + } + + #[tokio::test] + async fn failed_storage_does_not_accept_jobs_or_settings() { + let directory = Temp::new(); + let manager = manager(&directory.0); + let original = manager.settings().path; + std::fs::create_dir(directory.0.join("state/downloads.json.tmp")).unwrap(); + assert!(manager + .enqueue("jNQXAC9IVRw".into(), "one".into(), "artist".into(), "".into()) + .is_err()); + assert!(manager.snapshot()["jobs"].as_array().unwrap().is_empty()); + assert!(manager.set_settings(settings(&directory.0.join("music"), 2)).is_err()); + assert_eq!(manager.settings().path, original); + std::fs::remove_dir(directory.0.join("state/downloads.json.tmp")).unwrap(); + manager.shutdown().await; + } + + #[test] + fn settings_reject_unsafe_limits_and_relative_destinations() { + let directory = Temp::new(); + assert!(sanitize_settings(settings(&directory.0, 0)).is_err()); + assert!(sanitize_settings(settings(&directory.0, 5)).is_err()); + assert!(sanitize_settings(settings(Path::new("relative/music"), 1)).is_err()); + assert_eq!(sanitize_settings(settings(&directory.0, 4)).unwrap().workers, 4); + } + + #[test] + fn restart_recovers_pending_jobs_and_preserves_saved_files() { + let directory = Temp::new(); + let mut queued = job(&directory.0); + queued.status = DownloadStatus::Queued; + let mut completed = job(&directory.0); + completed.status = DownloadStatus::Completed; + completed.file_path = Some(directory.0.join("kept.mp3").to_string_lossy().into_owned()); + let mut jobs = vec![queued, completed]; + assert!(recover_interrupted(&mut jobs)); + assert_eq!(jobs[0].status, DownloadStatus::Failed); + assert!(jobs[0].error.is_some()); + assert_eq!(jobs[1].status, DownloadStatus::Completed); + assert!(jobs[1].file_path.is_some()); + assert!(!recover_interrupted(&mut jobs)); + } + + #[test] + fn companions_publish_under_the_audio_stem_without_overwriting() { + let directory = Temp::new(); + let staging = create_staging(directory.0.to_str().unwrap(), "companions").unwrap(); + std::fs::write(staging.join("audio.mp3"), b"finished audio").unwrap(); + std::fs::write(staging.join("audio.jpg"), b"cover art").unwrap(); + std::fs::write(staging.join("audio.lrc"), b"[00:00.00] la").unwrap(); + // Empty and out-of-staging entries are dropped, never published. + std::fs::write(staging.join("audio.txt"), b"").unwrap(); + let outside = directory.0.join("stray.jpg"); + std::fs::write(&outside, b"not ours").unwrap(); + let sidecars = vec![ + staging.join("audio.jpg"), + staging.join("audio.lrc"), + staging.join("audio.txt"), + outside.clone(), + ]; + // Pre-place a cover at the base stem so a suffix-0 companion collision forces the whole set + // (audio + sidecars) to the next suffix, and prove the existing cover is never overwritten. + let j = job(&directory.0); + let base_audio = + compute_final_path(&j.settings, &j.title, &j.artists, &j.video_id, "mp3", None); + let base_cover = base_audio.with_extension("jpg"); + std::fs::write(&base_cover, b"existing cover").unwrap(); + + let mut warnings: Vec = Vec::new(); + let published = publish(&staging, &job(&directory.0), &sidecars, &mut warnings).unwrap(); + let audio = PathBuf::from(&published); + assert!(audio.file_name().unwrap().to_str().unwrap().contains("(1)")); + assert_eq!(std::fs::read(&audio).unwrap(), b"finished audio"); + // Cover and lyrics share the audio's exact stem and suffix. + assert_eq!(std::fs::read(audio.with_extension("jpg")).unwrap(), b"cover art"); + assert_eq!(std::fs::read(audio.with_extension("lrc")).unwrap(), b"[00:00.00] la"); + // The suffix-0 audio slot the collision rolled back is left empty, the pre-existing cover + // is untouched, and the empty/out-of-staging companions were dropped. + assert!(!base_audio.exists()); + assert_eq!(std::fs::read(&base_cover).unwrap(), b"existing cover"); + assert!(!audio.with_extension("txt").exists()); + // No filesystem error occurred, so the completed job carries no companion warnings. + assert!(warnings.is_empty()); + } +} diff --git a/crates/ryotunesd/src/gtk_thread.rs b/crates/ryotunesd/src/gtk_thread.rs new file mode 100644 index 0000000..0ae8b81 --- /dev/null +++ b/crates/ryotunesd/src/gtk_thread.rs @@ -0,0 +1,78 @@ +//! One thread owns GTK. Everything WebKit is marshalled onto it; nothing else touches GTK. + +use std::future::Future; + +use tokio::sync::oneshot; + +#[derive(Clone)] +pub struct Gtk { + ctx: glib::MainContext, +} + +impl Gtk { + /// Start the GTK main loop on its own thread and return a handle. Panics if GTK cannot + /// initialise (no display): the daemon then refuses to start, which is the right answer + /// because the cipher/PoToken path needs a display. + pub fn start() -> Gtk { + // WebKitGTK defaults to a GL-accelerated compositor. Headless/offscreen (and some + // Wayland + proprietary-GPU sessions) can't hand it a GL context, and WebKit then aborts + // the whole process with "GDK is not able to create a GL context". The cipher/PoToken + // views never paint, so force the software path before GTK or any web process starts. + // (`set_var` is safe on the 2021 edition and this runs before the GTK thread exists.) + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("gtk".into()) + .spawn(move || { + gtk::init().expect("GTK init (is WAYLAND_DISPLAY/DISPLAY set?)"); + // Acquire the thread-default context this thread owns so `invoke` from other + // threads queues onto *our* loop, and `gtk::main` below pumps it. + let ctx = glib::MainContext::default(); + tx.send(ctx.clone()).unwrap(); + gtk::main(); + }) + .expect("spawn gtk thread"); + Gtk { ctx: rx.recv().expect("gtk thread reported its context") } + } + + /// Run `f` on the GTK thread and await its result. + pub fn call( + &self, + f: impl FnOnce() -> R + Send + 'static, + ) -> impl Future { + let (tx, rx) = oneshot::channel(); + self.ctx.invoke(move || { + let _ = tx.send(f()); + }); + async move { rx.await.expect("gtk closure ran") } + } +} + +/// Short-circuit process exit so WebKitGTK's own atexit teardown never runs. +/// +/// GTK/WebKit are initialised on the GTK worker thread, so WebKit treats that thread as its main +/// thread. libc runs exit handlers on the *process* main thread, and WebKit's global-context unref +/// there trips a main-thread `RELEASE_ASSERT` and `abort()`s — after all real work is done. We +/// register a glibc `on_exit` handler (it receives the true status) that `_exit`s immediately, +/// preserving the exit code and skipping WebKit's broken teardown. +/// +/// WebKit registers its global-context destructor lazily, when the first `WebView` is built, so +/// this MUST be called *after* that (exit handlers run last-registered-first). Callers install it +/// once, right after building their first view; the `Once` makes repeat calls free. +pub(crate) fn install_clean_exit() { + use std::os::raw::{c_int, c_void}; + use std::sync::Once; + + extern "C" { + fn on_exit(f: extern "C" fn(c_int, *mut c_void), arg: *mut c_void) -> c_int; + } + extern "C" fn hard_exit(status: c_int, _: *mut c_void) { + unsafe { libc::_exit(status) } + } + + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { + on_exit(hard_exit, std::ptr::null_mut()); + }); +} diff --git a/crates/ryotunesd/src/js.rs b/crates/ryotunesd/src/js.rs new file mode 100644 index 0000000..067700a --- /dev/null +++ b/crates/ryotunesd/src/js.rs @@ -0,0 +1,290 @@ +//! `JsBridge` over an offscreen WebKitGTK view per label. Same contract as the Tauri host's +//! `webview.rs`: sync evals round-trip once; async work parks its result in `window.__slots`. +//! +//! Unlike the Tauri port, a `webkit2gtk::WebView` is `!Send`, so it can never be stored in the +//! `Arc>` the plan sketched (that would make `GtkJs` `!Send` and fail `JsBridge`'s +//! `Send + Sync` bound). The live views therefore live in a GTK-thread-local registry and never +//! leave that thread; every operation is marshalled onto it through `Gtk::call`. A separate, +//! `Send` set of live labels backs the synchronous `exists()`. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use javascriptcore::ValueExt; +use ryotunes_core::host::{JsBridge, JsError, JsSession}; +use serde_json::Value; +use webkit2gtk::{gio, WebView, WebViewExt}; + +use crate::gtk_thread::Gtk; + +thread_local! { + /// Every live offscreen view, owned entirely by the GTK thread. + static VIEWS: RefCell> = RefCell::new(HashMap::new()); +} + +/// What an eval funnels back through the oneshot: the JSON of the value, the view being gone, or an +/// engine error — so the async side maps each to the right `JsError`. +enum EvalOut { + Json(String), + Gone, + Failed(String), +} + +pub struct GtkJs { + gtk: Gtk, + /// Labels whose view is currently registered on the GTK thread. Mirrors `VIEWS` so `exists()` + /// can answer without hopping threads. + labels: Arc>>, +} + +impl GtkJs { + pub fn new(gtk: Gtk) -> Self { + GtkJs { gtk, labels: Arc::new(Mutex::new(HashSet::new())) } + } +} + +#[derive(Clone)] +pub struct Session { + gtk: Gtk, + label: String, + labels: Arc>>, +} + +#[async_trait::async_trait] +impl JsBridge for GtkJs { + async fn create( + &self, + label: &str, + harness_html: &str, + init_script: &str, + ) -> Result, JsError> { + self.reclaim(label).await; + let init = format!( + "window.__jserr=null;window.__slots={{}};\ + window.addEventListener('error',function(e){{window.__jserr=String((e&&e.message)||e);}});\ + window.onunhandledrejection=function(e){{window.__jserr=String((e.reason&&e.reason.message)||e.reason);}};\n{init_script}" + ); + let data_url = format!("data:text/html,{}", urlencoding::encode(harness_html)); + let label_s = label.to_owned(); + let labels = self.labels.clone(); + self.gtk + .call(move || { + use gtk::prelude::*; + use webkit2gtk::{ + UserContentInjectedFrames, UserContentManager, UserContentManagerExt, + UserScript, UserScriptInjectionTime, + }; + let ucm = UserContentManager::new(); + ucm.add_script(&UserScript::new( + &init, + UserContentInjectedFrames::TopFrame, + UserScriptInjectionTime::Start, + &[], + &[], + )); + let view = WebView::builder().user_content_manager(&ucm).build(); + // WebKit's aborting exit-teardown registers when the first view is built; register + // our clean-exit shim now so it runs before it. + crate::gtk_thread::install_clean_exit(); + let win = gtk::OffscreenWindow::new(); + win.add(&view); + win.show_all(); + view.load_uri(&data_url); + VIEWS.with(|v| v.borrow_mut().insert(label_s.clone(), view)); + labels.lock().unwrap().insert(label_s); + }) + .await; + let session = + Session { gtk: self.gtk.clone(), label: label.to_owned(), labels: self.labels.clone() }; + let deadline = Instant::now() + Duration::from_secs(12); + loop { + if matches!( + session + .eval_json("location.protocol==='data:'".into(), Duration::from_millis(800)) + .await, + Ok(Value::Bool(true)) + ) { + tracing::info!(label, "js bridge OK"); + return Ok(Box::new(session)); + } + if Instant::now() >= deadline { + session.destroy(); + return Err(JsError::Timeout(Duration::from_secs(12))); + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + } + + async fn reclaim(&self, label: &str) { + let label = label.to_owned(); + let labels = self.labels.clone(); + self.gtk + .call(move || { + if let Some(view) = VIEWS.with(|v| v.borrow_mut().remove(&label)) { + use gtk::prelude::*; + if let Some(w) = view.toplevel() { + unsafe { w.destroy() }; + } + } + labels.lock().unwrap().remove(&label); + }) + .await; + } +} + +#[async_trait::async_trait] +impl JsSession for Session { + fn eval(&self, js: &str) -> Result<(), JsError> { + if !self.labels.lock().unwrap().contains(&self.label) { + return Err(JsError::Gone(self.label.clone())); + } + let js = js.to_owned(); + let label = self.label.clone(); + // Fire and forget on the GTK thread; the result is discarded like Tauri's `eval`. + let _ = self.gtk.call(move || { + VIEWS.with(|v| { + if let Some(view) = v.borrow().get(&label) { + view.evaluate_javascript(&js, None, None, None::<&gio::Cancellable>, |_| {}); + } + }); + }); + Ok(()) + } + + async fn eval_json(&self, js: String, timeout: Duration) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel::(); + let label = self.label.clone(); + self.gtk + .call(move || { + VIEWS.with(|v| match v.borrow().get(&label) { + None => { + let _ = tx.send(EvalOut::Gone); + } + Some(view) => { + view.evaluate_javascript( + &js, + None, + None, + None::<&gio::Cancellable>, + move |res| { + let out = match res { + Ok(v) => EvalOut::Json( + v.to_json(0) + .map(|g| g.to_string()) + .unwrap_or_else(|| "null".into()), + ), + Err(e) => EvalOut::Failed(e.to_string()), + }; + let _ = tx.send(out); + }, + ); + } + }); + }) + .await; + let out = tokio::time::timeout(timeout, rx) + .await + .map_err(|_| JsError::Timeout(timeout))? + .map_err(|_| JsError::Eval("callback dropped".into()))?; + let raw = match out { + EvalOut::Gone => return Err(JsError::Gone(self.label.clone())), + EvalOut::Failed(e) => return Err(JsError::Eval(e)), + EvalOut::Json(raw) => raw, + }; + if raw.is_empty() || raw == "null" || raw == "undefined" { + return Ok(Value::Null); + } + serde_json::from_str(&raw).map_err(|e| JsError::Eval(format!("bad json {raw:?}: {e}"))) + } + + async fn call_async(&self, expr: &str, timeout: Duration) -> Result { + // Verbatim port of `webview.rs` `call_async` + `poll_json` on top of `eval`/`eval_json`. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.eval(&format!( + "(async()=>{{try{{window.__slots['{id}']={{ok:1,v:await ({expr})}};}}\ + catch(e){{window.__slots['{id}']={{ok:0,e:String((e&&e.message)||e)}};}}}})();" + ))?; + let deadline = Instant::now() + timeout; + let slot = loop { + match self + .eval_json(format!("(window.__slots['{id}']||null)"), Duration::from_secs(3)) + .await + { + Ok(v) if !v.is_null() => break v, + Ok(_) => {} + Err(JsError::Gone(l)) => return Err(JsError::Gone(l)), + Err(_) => {} + } + if let Ok(Value::String(msg)) = + self.eval_json("window.__jserr||null".into(), Duration::from_secs(3)).await + { + return Err(JsError::BadEnvironment(msg)); + } + if Instant::now() >= deadline { + return Err(JsError::Timeout(timeout)); + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + let _ = self.eval(&format!("delete window.__slots['{id}'];")); + if slot.get("ok").and_then(Value::as_i64) == Some(1) { + Ok(slot.get("v").cloned().unwrap_or(Value::Null)) + } else { + Err(JsError::Eval( + slot.get("e").and_then(Value::as_str).unwrap_or("async call failed").to_owned(), + )) + } + } + + fn exists(&self) -> bool { + self.labels.lock().unwrap().contains(&self.label) + } + + fn destroy(&self) { + let label = self.label.clone(); + let labels = self.labels.clone(); + let _ = self.gtk.call(move || { + if let Some(view) = VIEWS.with(|v| v.borrow_mut().remove(&label)) { + use gtk::prelude::*; + if let Some(w) = view.toplevel() { + unsafe { w.destroy() }; + } + } + labels.lock().unwrap().remove(&label); + }); + } + + fn clone_session(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Needs a Wayland/X11 display: `cargo test -p ryotunesd -- --ignored js_round_trip`. + #[tokio::test] + #[ignore] + async fn js_round_trip() { + let js = GtkJs::new(Gtk::start()); + let s = js + .create("t", "", "window.x=41;") + .await + .unwrap(); + assert_eq!( + s.eval_json("window.x+1".into(), Duration::from_secs(3)).await.unwrap(), + Value::from(42) + ); + assert_eq!( + s.call_async("Promise.resolve('ok')", Duration::from_secs(3)).await.unwrap(), + Value::from("ok") + ); + assert!(s.exists()); + s.destroy(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!s.exists()); + } +} diff --git a/crates/ryotunesd/src/lifecycle.rs b/crates/ryotunesd/src/lifecycle.rs new file mode 100644 index 0000000..3942e03 --- /dev/null +++ b/crates/ryotunesd/src/lifecycle.rs @@ -0,0 +1,251 @@ +//! Idle exit: with nothing playing and no client subscribed, the daemon keeps the lightweight +//! backend alive for a bounded grace and then leaves cleanly, exactly as the Tauri tray-only build +//! does (`src-tauri/src/main_window.rs` `schedule_idle_exit` / `IDLE_EXIT_GRACE`). The deadline is +//! armed when playback stops or the last subscriber disconnects, and cancelled when playback starts +//! or a client subscribes. +//! +//! The decision is a pure state machine ([`IdleTimer`]) so it can be driven by a fake clock in +//! tests; [`Lifecycle`] wraps it for the running daemon, spawning a single tokio timer per arm and +//! invalidating stale timers through an epoch counter (the same trick `main_window.rs` uses). + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc::UnboundedSender; + +/// Idle grace before a tray-only daemon exits: one minute after the last client is gone with +/// nothing playing (the client quits itself a minute into a pause when its window is off screen, +/// so a paused, closed Ryotunes is fully out of memory ~2 minutes after the pause). Socket +/// activation brings the daemon back on the next `ryotunes`, media key or MPRIS call. The +/// `RYOTUNESD_IDLE_EXIT_SECS` environment variable overrides it (manual verification uses 10). +const DEFAULT_IDLE_EXIT_SECS: u64 = 60; + +fn idle_exit_grace() -> Duration { + let secs = std::env::var("RYOTUNESD_IDLE_EXIT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&s| s > 0) + .unwrap_or(DEFAULT_IDLE_EXIT_SECS); + Duration::from_secs(secs) +} + +/// The pure arming logic: eligible for idle exit when nothing is playing and no client is +/// subscribed. `refresh` arms the deadline on the first idle moment and clears it the instant the +/// daemon becomes busy again; every event routes through it so arm/cancel/re-arm can never drift. +struct IdleTimer { + grace: Duration, + deadline: Option, + playing: bool, + clients: usize, + /// A queued or active download holds the daemon open exactly like playback or a subscriber: + /// closing the UI must never abandon a download in flight. Driven by [`Lifecycle:: + /// downloads_busy_changed`] off the downloads manager's own active/queued count, so it never + /// borrows the subscriber count for this. + downloading: bool, +} + +impl IdleTimer { + fn new(grace: Duration) -> Self { + IdleTimer { grace, deadline: None, playing: false, clients: 0, downloading: false } + } + + fn eligible(&self) -> bool { + self.clients == 0 && !self.playing && !self.downloading + } + + /// Arm the deadline when newly idle (keeping an already-running deadline so the grace is not + /// pushed out by redundant events), clear it the moment the daemon is busy again. + fn refresh(&mut self, now: Instant) { + if self.eligible() { + if self.deadline.is_none() { + self.deadline = Some(now + self.grace); + } + } else { + self.deadline = None; + } + } + + fn set_playing(&mut self, now: Instant, playing: bool) { + self.playing = playing; + self.refresh(now); + } + + fn set_downloading(&mut self, now: Instant, downloading: bool) { + self.downloading = downloading; + self.refresh(now); + } + + fn client_connected(&mut self, now: Instant) { + self.clients += 1; + self.refresh(now); + } + + fn client_gone(&mut self, now: Instant) { + self.clients = self.clients.saturating_sub(1); + self.refresh(now); + } + + /// The armed deadline has passed and the daemon is still idle. + fn expired(&self, now: Instant) -> bool { + matches!(self.deadline, Some(d) if now >= d) + } +} + +/// Runtime wrapper around [`IdleTimer`]. Each transition into the armed state spawns one timer that +/// sleeps to the deadline and, if it is still the current arm (epoch unchanged) and the daemon is +/// still idle, sends on the quit channel — the daemon then runs `shutdown_for_quit` and exits 0. +pub struct Lifecycle { + inner: Mutex, + epoch: AtomicU64, + quit: UnboundedSender<()>, +} + +impl Lifecycle { + /// Build the lifecycle and arm the startup idle deadline: a freshly activated daemon with no + /// subscriber and nothing playing is already idle and must not linger forever. + pub fn new(quit: UnboundedSender<()>) -> Arc { + let this = Arc::new(Lifecycle { + inner: Mutex::new(IdleTimer::new(idle_exit_grace())), + epoch: AtomicU64::new(0), + quit, + }); + this.apply(IdleTimer::refresh); + this + } + + /// Playback started or stopped. A paused track counts as not playing, exactly as the Tauri + /// policy treats it. + pub fn playing_changed(self: &Arc, playing: bool) { + self.apply(|t, now| t.set_playing(now, playing)); + } + + /// A client subscribed to events (the daemon's equivalent of a visible UI). + pub fn client_connected(self: &Arc) { + self.apply(IdleTimer::client_connected); + } + + /// A subscribed client's connection ended. + pub fn client_gone(self: &Arc) { + self.apply(IdleTimer::client_gone); + } + + /// The downloads manager has queued/active work, or has just drained to none. A busy manager + /// pins the daemon open across a UI close (a download that outlives its window), and clearing + /// it re-arms the idle grace like any other transition back to idle. + pub fn downloads_busy_changed(self: &Arc, busy: bool) { + self.apply(|t, now| t.set_downloading(now, busy)); + } + + /// Apply a transition under the lock and reconcile the timer: on a fresh arm (no deadline -> + /// deadline) schedule one waiter; on a cancel (deadline -> none) bump the epoch so any pending + /// waiter no-ops when it wakes. + fn apply(self: &Arc, f: impl FnOnce(&mut IdleTimer, Instant)) { + let (before, after) = { + let mut timer = self.inner.lock().unwrap(); + let before = timer.deadline; + f(&mut timer, Instant::now()); + (before, timer.deadline) + }; + match (before, after) { + (None, Some(deadline)) => { + let epoch = self.epoch.fetch_add(1, Ordering::AcqRel) + 1; + self.schedule(epoch, deadline); + } + (Some(_), None) => { + self.epoch.fetch_add(1, Ordering::AcqRel); + } + _ => {} + } + } + + fn schedule(self: &Arc, epoch: u64, deadline: Instant) { + let this = self.clone(); + tokio::spawn(async move { + tokio::time::sleep(deadline.saturating_duration_since(Instant::now())).await; + if this.epoch.load(Ordering::Acquire) != epoch { + return; // a later arm/cancel superseded this deadline + } + let expired = this.inner.lock().unwrap().expired(Instant::now()); + if expired { + tracing::info!( + "idle grace elapsed with no subscriber and nothing playing; exiting" + ); + let _ = this.quit.send(()); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arms_cancels_expires_and_rearms() { + let grace = Duration::from_secs(300); + let t0 = Instant::now(); + let mut t = IdleTimer::new(grace); + + // Startup idle: the first refresh arms a deadline one grace out. + t.refresh(t0); + let d0 = t.deadline.expect("armed when idle at startup"); + assert_eq!(d0, t0 + grace); + assert!(!t.expired(t0)); + assert!(!t.expired(t0 + grace - Duration::from_secs(1))); + + // A client subscribes: cancelled, and no amount of elapsed time expires it. + t.client_connected(t0 + Duration::from_secs(10)); + assert!(t.deadline.is_none()); + assert!(!t.expired(t0 + grace * 2)); + + // Playback also holds it open once the client leaves. + t.set_playing(t0 + Duration::from_secs(20), true); + t.client_gone(t0 + Duration::from_secs(30)); + assert!(t.deadline.is_none(), "playing keeps it alive with no client"); + + // Re-arm from a fresh instant when playback stops with nobody listening. + let t1 = t0 + Duration::from_secs(40); + t.set_playing(t1, false); + let d1 = t.deadline.expect("re-armed when idle again"); + assert_eq!(d1, t1 + grace); + + // Expire past the new deadline. + assert!(!t.expired(d1 - Duration::from_secs(1))); + assert!(t.expired(d1)); + assert!(t.expired(d1 + Duration::from_secs(5))); + } + + #[test] + fn a_download_in_flight_holds_the_daemon_open() { + let grace = Duration::from_secs(300); + let t0 = Instant::now(); + let mut t = IdleTimer::new(grace); + + // Idle at startup with a deadline armed, then a download is enqueued: the daemon must not + // idle-exit while it runs, even with no subscriber and nothing playing (closing the UI + // must never abandon a download). + t.refresh(t0); + assert!(t.deadline.is_some()); + t.set_downloading(t0 + Duration::from_secs(5), true); + assert!(t.deadline.is_none(), "an active download cancels the idle deadline"); + assert!(!t.expired(t0 + grace * 2), "no elapsed time expires it while downloading"); + + // The queue drains: idle again, so the grace re-arms from that instant. + let t1 = t0 + Duration::from_secs(30); + t.set_downloading(t1, false); + assert_eq!(t.deadline.expect("re-armed once downloads drain"), t1 + grace); + } + + #[test] + fn redundant_idle_events_do_not_push_out_the_deadline() { + let grace = Duration::from_secs(300); + let t0 = Instant::now(); + let mut t = IdleTimer::new(grace); + t.refresh(t0); + let armed = t.deadline.unwrap(); + // A second "still not playing" a minute later must not reset the grace. + t.set_playing(t0 + Duration::from_secs(60), false); + assert_eq!(t.deadline.unwrap(), armed); + } +} diff --git a/crates/ryotunesd/src/login.rs b/crates/ryotunesd/src/login.rs new file mode 100644 index 0000000..21bf1b3 --- /dev/null +++ b/crates/ryotunesd/src/login.rs @@ -0,0 +1,220 @@ +//! The visible Google sign-in window (authentication flow Path A), ported from +//! `src-tauri/src/login_webview.rs` onto the GTK thread. It opens a real WebKitGTK window with a +//! spoofed desktop UA, refuses any navigation off Google/YouTube, watches for the redirect back to +//! music.youtube.com, harvests the youtube cookies from the shared jar and hands them to the core. +//! +//! Persistent (the default `WebContext`) on purpose: the webview keeps its own Google session, so a +//! later re-login is one click with no password/paste. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use tokio::sync::oneshot; + +use ryotunes_core::host::{LoginError, LoginFlow, LoginResult}; +use ryotunes_core::session::{allowed_login_navigation, LOGIN_URL}; + +use crate::gtk_thread::Gtk; + +/// WebKitGTK is a WebKit engine, so a macOS Safari UA is the most internally-consistent spoof and +/// the least likely to trip Google's "this browser may not be secure" block. **Tune here** if +/// Google rejects it — this is the fragile part (authentication flow Path A). +const LOGIN_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 \ + (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15"; + +/// The cookie jar is read for this origin — the same one YouTube Music serves from. +const MUSIC_ORIGIN: &str = "https://music.youtube.com"; + +/// Success or a reason the sign-in ended, funneled through the oneshot the window resolves. +type Outcome = Result; + +/// The host's [`LoginFlow`]: one visible Google sign-in window per call, on the GTK thread. +pub struct GtkLogin { + gtk: Gtk, +} + +impl GtkLogin { + pub fn new(gtk: Gtk) -> Self { + GtkLogin { gtk } + } +} + +#[async_trait::async_trait] +impl LoginFlow for GtkLogin { + async fn sign_in(&self) -> Result { + let (tx, rx) = oneshot::channel::(); + let tx = Arc::new(Mutex::new(Some(tx))); + self.gtk.call(move || build_login_window(tx)).await; + // The sender is dropped when the window is torn down without resolving → cancelled. + rx.await.unwrap_or(Err(LoginError::Cancelled)) + } +} + +/// Build the sign-in window on the GTK thread and wire its two exits: a finished load whose jar +/// carries the auth cookies resolves `Ok`; the user closing the window resolves `Err(Cancelled)`. +fn build_login_window(tx: Arc>>>) { + use gtk::prelude::*; + use webkit2gtk::{ + gio, CookieManagerExt, LoadEvent, NavigationPolicyDecision, NavigationPolicyDecisionExt, + PolicyDecisionExt, PolicyDecisionType, SettingsExt, URIRequestExt, WebContext, + WebContextExt, WebView, WebViewExt, + }; + + let view = WebView::new(); + // See `gtk_thread::install_clean_exit`: register our exit shim right after WebKit's own + // aborting teardown registers (on the first view build), in case login opens the first view. + crate::gtk_thread::install_clean_exit(); + if let Some(settings) = WebViewExt::settings(&view) { + settings.set_user_agent(Some(LOGIN_UA)); + } + + // Allow-list: only https on Google/YouTube. A phishing redirect can never keep the window. + view.connect_decide_policy(|_view, decision, kind| { + if kind != PolicyDecisionType::NavigationAction + && kind != PolicyDecisionType::NewWindowAction + { + return false; // not a navigation — let WebKit apply its default + } + let uri = decision + .downcast_ref::() + .and_then(|d| d.navigation_action()) + .and_then(|a| a.request()) + .and_then(|r| r.uri()); + let allowed = uri + .as_deref() + .and_then(|u| url::Url::parse(u).ok()) + .map(|u| allowed_login_navigation(&u)) + .unwrap_or(false); + if allowed { + decision.use_(); + } else { + decision.ignore(); + } + true // we made the decision + }); + + // On every finished load, read the youtube jar; once SAPISID is present sign-in is complete. + let tx_load = tx.clone(); + view.connect_load_changed(move |view, event| { + if event != LoadEvent::Finished { + return; + } + let Some(cm) = WebContext::default().and_then(|c| c.cookie_manager()) else { + return; + }; + let tx = tx_load.clone(); + let view = view.clone(); + cm.cookies(MUSIC_ORIGIN, None::<&gio::Cancellable>, move |res| { + let Ok(cookies) = res else { + return; + }; + // `soup::Cookie`'s accessors are inherent (`&mut self`) so no `soup` dependency is + // needed to lift each cookie into a plain (name, value, domain) triple. + let triples: Vec<(String, String, String)> = cookies + .into_iter() + .map(|mut c| { + ( + c.name().map(|g| g.to_string()).unwrap_or_default(), + c.value().map(|g| g.to_string()).unwrap_or_default(), + c.domain().map(|g| g.to_string()).unwrap_or_default(), + ) + }) + .collect(); + let pairs = youtube_cookie_pairs(triples); + if innertube::cookie_sapisid(&cookie_header(&pairs)).is_some() { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(Ok(LoginResult { cookies: pairs, authuser: 0 })); + } + if let Some(w) = view.toplevel() { + unsafe { w.destroy() }; + } + } + }); + }); + + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("Sign in to YouTube Music"); + window.set_default_size(480, 720); + window.add(&view); + + // Closing the window before the cookies land is a cancel. + let tx_cancel = tx; + window.connect_delete_event(move |_, _| { + if let Some(tx) = tx_cancel.lock().unwrap().take() { + let _ = tx.send(Err(LoginError::Cancelled)); + } + glib::Propagation::Proceed + }); + + window.show_all(); + view.load_uri(LOGIN_URL); +} + +/// Domain-match by hand rather than with `cookies_for_url` (matches +/// `src-tauri/src/login_webview.rs`). Anything outside youtube.com is dropped, google.com included: +/// this becomes a `Cookie` header sent to YouTube, and a cookie without a domain we recognise +/// doesn't belong in it. +/// +/// Input is `(name, value, domain)` triples: the daemon lifts them from `soup::Cookie` (whose +/// domain may keep a leading dot), the tests build them directly. +fn youtube_cookie_pairs(cookies: Vec<(String, String, String)>) -> Vec<(String, String)> { + // Normalise the domain the way Tauri's `Cookie::domain()` did (leading dot stripped), then sort + // so the most specific domain is inserted last and wins a name collision, the way a browser + // resolves one. + let mut cookies: Vec<(String, String, String)> = cookies + .into_iter() + .map(|(name, value, domain)| { + let domain = domain.strip_prefix('.').map(str::to_owned).unwrap_or(domain); + (name, value, domain) + }) + .collect(); + cookies.sort_by_key(|(_, _, domain)| domain.len()); + let mut jar = BTreeMap::new(); + for (name, value, domain) in cookies { + if domain == "youtube.com" || domain.ends_with(".youtube.com") { + jar.insert(name, value); + } + } + jar.into_iter().collect() +} + +/// The `Cookie` header a browser would send: `name=value` joined with `; `. +fn cookie_header(pairs: &[(String, String)]) -> String { + pairs.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `"SAPISID=abc; Domain=.youtube.com"` → `(name, value, domain)`, as the cookie jar hands them. + fn cookie(s: &str) -> (String, String, String) { + let (nv, domain) = s.split_once("; Domain=").unwrap_or((s, "")); + let (name, value) = nv.split_once('=').unwrap(); + (name.to_string(), value.to_string(), domain.to_string()) + } + + #[test] + fn keeps_the_youtube_jar_and_drops_everything_else() { + // `.youtube.com` is where the auth cookies actually live. + let header = cookie_header(&youtube_cookie_pairs(vec![ + cookie("SAPISID=abc; Domain=.youtube.com"), + cookie("SID=def; Domain=.youtube.com"), + cookie("VISITOR_INFO1_LIVE=xyz; Domain=music.youtube.com"), + cookie("SAPISID=notthisone; Domain=.google.com"), + cookie("nodomain=1"), + ])); + assert_eq!(header, "SAPISID=abc; SID=def; VISITOR_INFO1_LIVE=xyz"); + // The check the harvest gates on: no SAPISID means sign-in silently gives up. + assert_eq!(innertube::cookie_sapisid(&header), Some("abc")); + } + + #[test] + fn the_most_specific_domain_wins_a_name_collision() { + let header = cookie_header(&youtube_cookie_pairs(vec![ + cookie("PREF=broad; Domain=.youtube.com"), + cookie("PREF=specific; Domain=music.youtube.com"), + ])); + assert_eq!(header, "PREF=specific"); + } +} diff --git a/crates/ryotunesd/src/main.rs b/crates/ryotunesd/src/main.rs new file mode 100644 index 0000000..5f5d507 --- /dev/null +++ b/crates/ryotunesd/src/main.rs @@ -0,0 +1,179 @@ +mod app; +mod browser_auth; +mod diagnostics; +mod download_media; +mod downloads; +mod gtk_thread; +mod js; +mod lifecycle; +mod login; +mod methods; +mod server; +mod sink; +mod tray; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::Arc; + +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; + +fn main() -> anyhow::Result<()> { + // Panics and warn+ tracing land in the diagnostics ring + rolling file before anything + // else can fail, so even a daemon that dies on launch leaves evidence for the next session. + diagnostics::install_panic_hook(); + let logs_dir = app::paths().data_dir.join("logs"); + let _diagnostics = diagnostics::Diagnostics::install(&logs_dir); + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .with(diagnostics::CaptureLayer) + .init(); + + let path = ryotunes_protocol::socket_path(); + // Single instance: hold the lock and run, or hand a `show` to the incumbent and exit 0. + let lock = match acquire_or_show(&path)? { + Some(lock) => lock, + None => return Ok(()), + }; + + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + let gtk = gtk_thread::Gtk::start(); + let sink = Arc::new(sink::SocketSink::default()); + let js: Arc = Arc::new(js::GtkJs::new(gtk.clone())); + let login: Arc = + Arc::new(login::GtkLogin::new(gtk.clone())); + + rt.block_on(async { + let (quit_tx, mut quit_rx) = tokio::sync::mpsc::unbounded_channel(); + // The idle-exit lifecycle arms itself immediately: an activated daemon with no subscriber + // and nothing playing must not linger past the grace. + let lifecycle = lifecycle::Lifecycle::new(quit_tx.clone()); + + let paths = app::paths(); + let data_dir = paths.data_dir.clone(); + let (state, events, media_rx, lt_rx) = + app::build(paths, sink.clone(), js, login, &tokio::runtime::Handle::current())?; + app::spawn_pumps(state.clone(), events, media_rx, lt_rx, sink.clone(), lifecycle.clone()); + tray::spawn(state.clone(), quit_tx.clone(), sink.clone()); + + // Restore a cached Spotify session in the background so browsing and playback are ready + // without blocking startup. Logged, never fatal: no cached credentials is the common case. + // The result is also announced as a `spotify-auth` event: a client that subscribed before + // the restore finished (the server binds while this is still connecting — the premium + // check alone can take 5s) otherwise never learns the session came back, and shows its + // sign-in gate on every launch even though the credentials are live. + { + let state = state.clone(); + tokio::spawn(async move { + match state.spotify.restore().await { + Ok(true) => { + tracing::info!("spotify: restored a cached session"); + state.emit("spotify-auth", serde_json::json!({ "state": "restored" })); + } + Ok(false) => tracing::debug!("spotify: no cached session to restore"), + Err(e) => { + tracing::warn!("spotify: restore failed: {:#}", e); + // Credentials exist on disk but the session is dead — tell the UI so the + // gate can explain rather than silently demanding a fresh sign-in. + if state.spotify.stored() { + state.emit( + "spotify-auth", + serde_json::json!({ + "state": "restore_failed", + "message": format!("Spotify sign-in no longer works: {e:#}"), + }), + ); + } + } + } + }); + } + + // The downloads subsystem shares the app data dir for its durable queue/history, reuses the + // core SoundCloud client to resolve `sc:track:` permalinks, and reports queued/active work + // to the lifecycle so closing the UI never abandons a download in flight. + let downloads = downloads::Downloads::new( + sink.clone(), + state.soundcloud.clone(), + data_dir, + lifecycle.clone(), + Some(state.clone()), + )?; + + let server = server::Server::bind(&path, sink.clone(), lifecycle)?; + let methods = Arc::new(methods::Methods { + state: state.clone(), + quit: quit_tx, + downloads: downloads.clone(), + }); + // A `systemctl stop` (or any `kill`) sends SIGTERM: handle it exactly like ctrl_c / an + // explicit quit so the awaited teardown below runs — cancelling and reaping every download + // process group — rather than leaving that to the runtime `Drop`. + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = server.run(methods) => {} + _ = quit_rx.recv() => {} + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + // Explicit teardown: stop mpv, flush the resume position, unregister MPRIS, drop Discord and + // leave any Listen Together room — exactly what `main_window::request_quit` runs today. + // Reap downloads alongside playback teardown; neither subsystem waits for the other before + // it stops accepting work. + tokio::join!(downloads.shutdown(), state.shutdown_for_quit()); + Ok::<(), anyhow::Error>(()) + })?; + + drop(lock); + // systemd owns the socket file for an activated instance; only a self-bound socket is ours to + // remove. + if !server::socket_activated() { + let _ = std::fs::remove_file(&path); + } + Ok(()) +} + +/// Single-instance handshake, mirroring `tauri-plugin-single-instance`: hold `ryotunesd.sock.lock` +/// under `flock(LOCK_EX | LOCK_NB)` for the process lifetime, so a second daemon cannot fight this +/// one over the shared SQLite/mpv state. `Some(lock)` — we are the instance, keep the handle open. +/// `None` — an incumbent holds the lock; we asked it to `show`, printed its reply, and should exit 0. +fn acquire_or_show(path: &Path) -> anyhow::Result> { + let dir = path.parent().expect("socket path has a parent"); + std::fs::create_dir_all(dir)?; + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + let lock_path = dir.join("ryotunesd.sock.lock"); + let file = + std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&lock_path)?; + // Safe: a plain advisory-lock syscall on a fd we own. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + return Ok(Some(file)); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EWOULDBLOCK) { + return Err(anyhow::anyhow!("locking {}: {err}", lock_path.display())); + } + // Another ryotunesd is live: a second launch means "show the window" (or launch the client). + match forward_show(path) { + Ok(response) => println!("{response}"), + Err(e) => eprintln!("ryotunesd already running; `show` request failed: {e}"), + } + Ok(None) +} + +/// Connect to the incumbent's socket, request `show`, and return its one-line response. +fn forward_show(path: &Path) -> std::io::Result { + let mut stream = UnixStream::connect(path)?; + stream.write_all(b"{\"id\":1,\"method\":\"show\"}\n")?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + Ok(line.trim_end().to_string()) +} diff --git a/crates/ryotunesd/src/methods.rs b/crates/ryotunesd/src/methods.rs new file mode 100644 index 0000000..cbaeb2b --- /dev/null +++ b/crates/ryotunesd/src/methods.rs @@ -0,0 +1,2214 @@ +//! Every socket method. Names, parameter keys and result shapes are the Tauri commands' +//! (`src-tauri/src/commands.rs`); parameter objects use the same camelCase keys the Svelte +//! `ui/src/lib/api.ts` sends. The bodies here are ports of those command bodies: the seams that +//! needed Tauri (native file/folder dialogs) take an explicit `path` over the socket instead, and +//! the ones that only used the `AppHandle` for asset-scope allow-listing drop it (the daemon serves +//! no webview assets). Plus the control methods `hello`, `subscribe`, `quit` and `sign_in`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use innertube::{BrowseItem, PlaylistPage, PlaylistSort, Rating, SongItem, YouTubeClient}; +use ryotunes_core::db::LocalPlaylist; +use ryotunes_core::soundcloud_bridge; +use ryotunes_core::spotify::{ + is_sc_id, sc_playlist_id, sc_system_id, sc_track_id, sc_user_id, spotify_track_id, Provider, +}; +use ryotunes_core::spotify_bridge; +use ryotunes_core::state::{ + is_local_playlist_id, is_smart_playlist_id, song_to_track, AppState, RepeatMode, + LIKED_SONGS_ID, LIKED_SONGS_TITLE, LOCAL_PLAYLIST_PREFIX, ON_REPEAT_ID, ON_REPEAT_LIMIT, + ON_REPEAT_WINDOW_SECS, RECENTLY_PLAYED_ID, RECENTLY_PLAYED_WINDOW_SECS, REDISCOVER_ID, + REDISCOVER_OLDER_THAN_SECS, SMART_PLAYLIST_LIMIT, UI_SETTINGS, +}; +use ryotunes_core::{local, radio}; +use ryotunes_protocol::{ErrorBody, PROTOCOL_VERSION}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use crate::server::{Connection, Dispatch}; + +pub struct Methods { + pub state: Arc, + pub quit: tokio::sync::mpsc::UnboundedSender<()>, + pub downloads: Arc, +} + +fn arg(params: &Value, key: &str) -> Result { + serde_json::from_value(params.get(key).cloned().unwrap_or(Value::Null)) + .map_err(|e| ErrorBody { code: "bad_params".into(), message: format!("{key}: {e}") }) +} + +fn err(e: impl ToString) -> ErrorBody { + ErrorBody { code: "failed".into(), message: e.to_string() } +} + +fn ok(v: T) -> Result { + serde_json::to_value(v).map_err(err) +} + +fn null() -> Result { + Ok(Value::Null) +} + +// --- Spotify routing -------------------------------------------------------------------------- + +/// Guards the sign-in flow so two OAuth attempts never run at once (it is fire-and-forget). +static SPOTIFY_SIGNING_IN: AtomicBool = AtomicBool::new(false); +/// The authorization URL of the flow in progress, so a repeat `spotify_sign_in` (the user clicked +/// again because the browser never opened, or closed it) reopens the same page instead of +/// answering "started" and doing nothing visible. +static SPOTIFY_AUTH_URL: std::sync::Mutex> = std::sync::Mutex::new(None); +/// One `client.liked` page (mirrors the crate's private `LIKED_PAGE`); the liked-songs view pages +/// until a batch comes back short. +const SPOTIFY_LIKED_PAGE: usize = 100; + +/// Resets [`SPOTIFY_SIGNING_IN`] whenever the spawned flow ends — success, failure or panic. +struct SignInGuard; +impl Drop for SignInGuard { + fn drop(&mut self) { + SPOTIFY_SIGNING_IN.store(false, Ordering::SeqCst); + *SPOTIFY_AUTH_URL.lock().unwrap_or_else(|e| e.into_inner()) = None; + } +} + +/// Guards the SoundCloud browser-import flow so two watchers never run at once. +static SC_SIGNING_IN: AtomicBool = AtomicBool::new(false); +/// How often the browser-import watcher re-reads the browser cookie stores, and how long the +/// user gets to finish signing in there before the flow gives up. +const SC_SIGN_IN_POLL_SECS: u64 = 2; +const SC_SIGN_IN_TIMEOUT_SECS: u64 = 5 * 60; + +/// Resets [`SC_SIGNING_IN`] whenever the capture flow ends — success, failure or panic. +struct SoundcloudSignInGuard; +impl Drop for SoundcloudSignInGuard { + fn drop(&mut self) { + SC_SIGNING_IN.store(false, Ordering::SeqCst); + } +} + +/// Finish a SoundCloud sign-in from an imported session: prove the bearer against /me before +/// persisting it, and carry the account name back for the status line. +async fn complete_soundcloud_sign_in( + st: &Arc, + mut auth: ryotunes_soundcloud::SoundcloudAuth, +) { + st.soundcloud.set_auth(Some(auth.clone())); + match st.soundcloud.me().await { + Ok(user) => { + auth.username = Some(user.username.clone()); + persist_soundcloud_auth(st, Some(&auth)); + st.soundcloud.set_auth(Some(auth)); + st.emit("soundcloud-auth", json!({ "state": "signed_in", "name": user.username })); + } + Err(e) => { + st.soundcloud.set_auth(None); + st.emit( + "soundcloud-auth", + json!({ + "state": "error", + "message": format!("SoundCloud sign-in failed: {e}"), + }), + ); + } + } +} + +/// Persist (or forget) the SoundCloud OAuth blob under the daemon-only settings key. The +/// crate's rotate hook writes the same key on every refresh; this writes it at sign-in and +/// sign-out, the two moments the lifetime changes. +fn persist_soundcloud_auth(st: &Arc, auth: Option<&ryotunes_soundcloud::SoundcloudAuth>) { + match auth.map(|a| serde_json::to_string(a)) { + Some(Ok(json)) => st.db.set_setting(ryotunes_core::state::SOUNDCLOUD_AUTH_KEY, &json), + Some(Err(e)) => tracing::error!(error = %e, "soundcloud: cannot serialize auth"), + None => st.db.delete_setting(ryotunes_core::state::SOUNDCLOUD_AUTH_KEY), + } +} + +/// The `{signedIn, name}` block for `soundcloud_status` and the subscribe snapshot. A stored +/// token that never verified reads as signed out (the blob only persists after a /me proof). +fn soundcloud_status_json(st: &Arc) -> Value { + match st.soundcloud.auth() { + Some(auth) => json!({ "signedIn": true, "name": auth.username }), + None => json!({ "signedIn": false, "name": null }), + } +} + +/// A Spotify crate error as the daemon's error body. +fn spotify_err(e: impl ToString) -> ErrorBody { + ErrorBody { code: "spotify".into(), message: e.to_string() } +} + +/// The error a by-prefix Spotify command returns when no session is signed in. +fn spotify_signed_out() -> ErrorBody { + ErrorBody { code: "spotify_signed_out".into(), message: "Sign in to Spotify".into() } +} + +/// A SoundCloud crate error as the daemon's error body. +fn soundcloud_err(e: impl ToString) -> ErrorBody { + ErrorBody { code: "soundcloud".into(), message: e.to_string() } +} + +#[async_trait::async_trait] +impl Dispatch for Methods { + async fn call( + &self, + method: &str, + params: Value, + conn: &Connection, + ) -> Result { + let st = &self.state; + match method { + // --- control methods ------------------------------------------------------------- + "hello" => { + ok(json!({ "protocol": PROTOCOL_VERSION, "daemon": env!("CARGO_PKG_VERSION") })) + } + "check_for_updates" => { + ok(ryotunes_core::update::check_for_updates().await.map_err(err)?) + } + "install_update" => { + let version = arg(¶ms, "version")?; + ok(ryotunes_core::update::install_update(version).await.map_err(err)?) + } + "subscribe" => { + conn.subscribe(); + let provider = st.spotify.selected().as_str(); + let spotify = spotify_status_json(st).await; + ok(json!({ + "playback": st.playback_snapshot().await, + "queue": st.queue_snapshot().await, + "settings": st.settings_snapshot(), + "auth": st.account_snapshot(), + "audioFx": st.audio_fx(), + "provider": provider, + "spotify": spotify, + "soundcloud": soundcloud_status_json(st), + "downloads": self.downloads.snapshot(), + })) + } + "quit" => { + let _ = self.quit.send(()); + null() + } + "sign_in" => { + st.sign_in().await; + null() + } + "show" => { + // Raise a subscribed client's window, or launch the client when none is listening. + crate::tray::show(&conn.sink); + null() + } + + // --- search ---------------------------------------------------------------------- + "search" => ok(st.search(&arg::(¶ms, "query")?).await.map_err(err)?), + "search_page" => { + let query = arg::(¶ms, "query")?; + if st.spotify.browsing_soundcloud() { + soundcloud_search_page(st, &query).await + } else if st.spotify.browsing_spotify().await { + spotify_search_page(st, &query).await + } else { + ok(st.search_page(&query).await.map_err(err)?) + } + } + "search_page_more" => { + if st.spotify.browsing_soundcloud() || st.spotify.browsing_spotify().await { + ok(json!({ "items": [], "continuation": null })) + } else { + ok(st.search_page_more(&arg::(¶ms, "token")?).await.map_err(err)?) + } + } + "search_all" => { + let query = arg::(¶ms, "query")?; + if st.spotify.browsing_soundcloud() { + soundcloud_search_all(st, &query).await + } else if st.spotify.browsing_spotify().await { + spotify_search_all(st, &query).await + } else { + ok(st.search_all(&query).await.map_err(err)?) + } + } + "search_all_more" => { + ok(st.search_all_more(&arg::(¶ms, "token")?).await.map_err(err)?) + } + "search_cards" => { + let query = arg::(¶ms, "query")?; + let category = arg::(¶ms, "category")?; + if st.spotify.browsing_soundcloud() { + soundcloud_search_cards(st, &query, &category).await + } else if st.spotify.browsing_spotify().await { + spotify_search_cards(st, &query, &category).await + } else { + ok(st.search_cards(&query, &category).await.map_err(err)?) + } + } + "search_cards_page" => ok(st + .search_cards_page( + &arg::(¶ms, "query")?, + &arg::(¶ms, "category")?, + ) + .await + .map_err(err)?), + "search_cards_more" => { + ok(st.search_cards_more(&arg::(¶ms, "token")?).await.map_err(err)?) + } + + // --- playback / queue ------------------------------------------------------------ + "play" => { + st.clone().play_song(arg(¶ms, "item")?).await; + null() + } + "prefetch_stream" => { + let st2 = st.clone(); + let video_id = arg::(¶ms, "videoId")?; + let is_upload = arg::>(¶ms, "isUpload")?.unwrap_or(false); + tokio::spawn(async move { st2.prefetch_stream(video_id, is_upload).await }); + null() + } + "play_index" => { + st.clone().play_index(arg(¶ms, "index")?).await; + null() + } + "remove_from_queue" => { + st.clone().remove_from_queue(arg(¶ms, "index")?).await; + null() + } + "clear_queued" => { + st.clone().clear_queued().await; + null() + } + "add_to_queue" => { + st.clone() + .add_to_queue( + arg(¶ms, "items")?, + arg(¶ms, "from")?, + arg(¶ms, "continuation")?, + ) + .await; + null() + } + "move_in_queue" => { + st.clone().move_in_queue(arg(¶ms, "from")?, arg(¶ms, "to")?).await; + null() + } + "play_next" => { + st.clone().play_next(arg(¶ms, "items")?, arg(¶ms, "from")?).await; + null() + } + "next_track" => { + st.clone().next_in_queue().await; + null() + } + "prev_track" => { + st.clone().prev_in_queue().await; + null() + } + "toggle_shuffle" => { + st.clone().toggle_shuffle().await; + null() + } + "set_repeat" => { + let mode = match arg::(¶ms, "mode")?.as_str() { + "off" => RepeatMode::Off, + "all" => RepeatMode::All, + "one" => RepeatMode::One, + other => return Err(err(format!("unknown repeat mode: {other}"))), + }; + st.clone().set_repeat(mode).await; + null() + } + "set_stop_after_current" => { + st.clone().set_stop_after_current(arg(¶ms, "enabled")?).await; + null() + } + "toggle_pause" => { + st.clone().resume_or_toggle().await; + null() + } + "seek" => { + let position = arg::(¶ms, "position")?; + if !position.is_finite() || position < 0.0 { + return Err(err("Seek position must be a non-negative finite number.")); + } + st.user_seek(position).await.map_err(err)?; + null() + } + "set_volume" => { + st.set_volume(arg(¶ms, "volume")?).map_err(err)?; + null() + } + "set_playback_params" => { + st.set_playback_params(arg(¶ms, "speed")?, arg(¶ms, "semitones")?) + .map_err(err)?; + null() + } + "set_audio_fx" => { + let fx: player::AudioFx = serde_json::from_value(params.clone()).map_err(err)?; + st.set_audio_fx(fx).map_err(err)?; + null() + } + "get_audio_fx" => ok(st.audio_fx()), + "get_queue" => ok(st.queue_snapshot().await), + "get_playback" => ok(st.playback_snapshot().await), + + // --- settings -------------------------------------------------------------------- + "get_settings" => ok(st.settings_snapshot()), + "discord_status" => ok(st.discord_status()), + "set_setting" => set_setting(st, ¶ms), + "get_personal" => ok(json!({ "personal": st.personal() })), + "set_personal" => { + st.set_personal(arg::(¶ms, "personal")?).map_err(err)?; + null() + } + "get_stream_clients" => { + let mut v = vec![innertube::MAIN_CLIENT.to_string()]; + v.extend(innertube::STREAM_FALLBACK_ORDER.iter().map(|s| s.to_string())); + for key in innertube::UPLOAD_FALLBACK_ORDER { + if !v.iter().any(|s| s == key) { + v.push(key.to_string()); + } + } + ok(v) + } + "clear_caches" => { + let rotate = arg::>(¶ms, "rotate")?.unwrap_or(true); + let refreshed = st.clear_caches(rotate).await; + ok(json!({ "visitorDataRefreshed": refreshed })) + } + + // --- auth ------------------------------------------------------------------------ + "get_account" => ok(st.account_snapshot()), + "get_account_identities" => ok(st.account_identities().await.map_err(err)?), + "switch_account" => ok(st + .switch_account(&arg::(¶ms, "selectionKey")?) + .await + .map_err(err)?), + "sign_out" => { + st.clone().sign_out().await; + null() + } + + // --- provider + Spotify auth ----------------------------------------------------- + "set_provider" => { + let provider = arg::(¶ms, "provider")?; + let selected = Provider::parse(&provider) + .ok_or_else(|| err(format!("unknown provider: {provider}")))?; + st.db.set_setting("provider", selected.as_str()); + st.spotify.select(selected); + st.emit("provider-changed", json!({ "provider": selected.as_str() })); + ok(json!({ "provider": selected.as_str() })) + } + "get_provider" => ok(json!({ "provider": st.spotify.selected().as_str() })), + "spotify_status" => ok(spotify_status_json(st).await), + "soundcloud_status" => ok(soundcloud_status_json(st)), + "spotify_sign_in" => { + if SPOTIFY_SIGNING_IN + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + // A flow is already waiting on the browser: hand the same URL out again. + let url = SPOTIFY_AUTH_URL.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(url) = url { + st.emit("spotify-auth", json!({ "state": "url", "url": url })); + } + return ok(json!({ "started": true })); + } + let task = st.clone(); + tokio::spawn(async move { + let _guard = SignInGuard; + let emitter = task.clone(); + let result = task + .spotify + .sign_in(move |url| { + *SPOTIFY_AUTH_URL.lock().unwrap_or_else(|e| e.into_inner()) = + Some(url.clone()); + emitter.emit("spotify-auth", json!({ "state": "url", "url": url })); + }) + .await; + match result { + Ok(()) => { + let name = spotify_name(&task).await; + task.emit( + "spotify-auth", + json!({ "state": "signed_in", "name": name }), + ); + } + Err(e) => { + task.emit( + "spotify-auth", + json!({ + "state": "error", + "message": format!("Spotify sign-in failed: {e:#}"), + }), + ); + } + } + }); + ok(json!({ "started": true })) + } + "spotify_sign_out" => { + st.spotify.sign_out().await; + st.emit("spotify-auth", json!({ "state": "signed_out" })); + ok(json!({ "signedIn": false })) + } + + // --- browse / library ------------------------------------------------------------ + "get_home" => { + // The merged home: YouTube Music is the spine (the default source, signed in + // or not), and every signed-in provider contributes its own shelves to the + // same page. A YouTube chip filter narrows the YouTube feed alone — the other + // providers have no equivalent filter — so a filtered page is YouTube-only. + let params = arg::>(¶ms, "params")?; + match params.as_deref() { + Some(p) => ok(st.home(Some(p)).await.map_err(err)?), + None => home_merged(st).await, + } + } + "get_home_more" => { + // The merged page's continuation is always the YouTube spine's: the provider + // shelves that ride with it are finite rows, not paged feeds. + ok(st.home_more(&arg::(¶ms, "token")?).await.map_err(err)?) + } + "get_library" => { + if st.spotify.browsing_soundcloud() { + ok(Vec::::new()) + } else if st.spotify.browsing_spotify().await { + spotify_library(st).await + } else { + get_library(st).await + } + } + "get_library_albums" => { + if st.spotify.browsing_soundcloud() { + ok(Vec::::new()) + } else if st.spotify.browsing_spotify().await { + spotify_library_albums(st).await + } else { + ok(st.library_albums().await.map_err(err)?) + } + } + "get_library_artists" => { + if st.spotify.browsing_soundcloud() { + ok(Vec::::new()) + } else if st.spotify.browsing_spotify().await { + spotify_library_artists(st).await + } else { + ok(st.library_artists().await.map_err(err)?) + } + } + "get_playlist" => get_playlist(st, ¶ms).await, + "get_playlist_more" => { + ok(st.playlist_more(&arg::(¶ms, "token")?).await.map_err(err)?) + } + "playlist_index" => ok(st.db.playlist_memberships()), + "sync_playlist_index" => sync_playlist_index(st).await, + "play_counts" => ok(st + .db + .play_counts(now_secs() - ON_REPEAT_WINDOW_SECS) + .into_iter() + .collect::>()), + "listening_stats" => listening_stats(st, ¶ms), + "get_album" => { + let id = arg::(¶ms, "id")?; + if let Some(aid) = id.strip_prefix("spotify:album:") { + spotify_album(st, aid).await + } else if let Some(pid) = sc_playlist_id(&id) { + soundcloud_album(st, pid).await + } else { + ok(st.album(&id).await.map_err(err)?) + } + } + "get_artist" => { + let id = arg::(¶ms, "id")?; + if let Some(aid) = id.strip_prefix("spotify:artist:") { + spotify_artist(st, aid).await + } else if let Some(uid) = sc_user_id(&id) { + soundcloud_artist(st, uid).await + } else { + ok(st.artist(&id).await.map_err(err)?) + } + } + "get_waveform" => { + let tid = sc_track_id(&arg::(¶ms, "id")?) + .ok_or_else(|| err("get_waveform expects a SoundCloud track id"))?; + soundcloud_waveform(st, tid).await + } + "get_sc_user" => { + let uid = sc_user_id(&arg::(¶ms, "id")?) + .ok_or_else(|| err("get_sc_user expects a SoundCloud user id"))?; + soundcloud_artist(st, uid).await + } + "get_browse_grid" => ok(st + .browse_grid( + &arg::(¶ms, "id")?, + arg::>(¶ms, "params")?.as_deref(), + ) + .await + .map_err(err)?), + + // --- local music (dialogs replaced by an explicit `path`) ------------------------ + "get_local_library" => ok(scan_local(st).await.map_err(err)?), + "add_local_folder" => { + let path = canonical_music_folder(&arg::(¶ms, "path")?).map_err(err)?; + local::add_folder(&st.db, path); + ok(Some(scan_local(st).await.map_err(err)?)) + } + "remove_local_folder" => { + local::remove_folder(&st.db, &arg::(¶ms, "path")?); + ok(scan_local(st).await.map_err(err)?) + } + + // --- playback of collections ----------------------------------------------------- + "play_playlist" => { + let items = arg::>(¶ms, "items")?; + let start = arg::>(¶ms, "start")?; + let source_id = arg::>(¶ms, "sourceId")? + .filter(|id| !is_local_playlist_id(id)); + let source_name = arg::>(¶ms, "sourceName")?; + let shuffle = arg::>(¶ms, "shuffle")?.unwrap_or(false); + let continuation = arg::>(¶ms, "continuation")?; + st.clone() + .play_tracks(items, start, source_id, source_name, shuffle, continuation) + .await; + null() + } + "start_radio" => { + let kind = arg::(¶ms, "kind")?; + let id = arg::(¶ms, "id")?; + let name = arg::>(¶ms, "name")?; + if radio::is_radio_id(&id) || is_local_playlist_id(&id) { + return Err(err("This item does not have a YouTube Music radio seed.")); + } + st.clone().start_radio(&kind, &id, name).await.map_err(err)?; + null() + } + "radio_stations" => ok(radio::stations( + arg::>(¶ms, "query")?.as_deref(), + arg::>(¶ms, "offset")?.unwrap_or(0), + arg::>(¶ms, "limit")?.unwrap_or(36), + ) + .await + .map_err(err)?), + "play_radio_station" => { + let station = radio::station_by_uuid(&arg::(¶ms, "stationUuid")?) + .await + .map_err(err)?; + st.clone().play_radio_station(station).await.map_err(err)?; + null() + } + + // --- portable playlist transfer (dialogs replaced by an explicit `path`) --------- + "export_playlist_file" => export_playlist_file(¶ms), + "import_playlist_file" => import_playlist_file(¶ms), + + // --- write actions --------------------------------------------------------------- + "rate" => { + let video_id = arg::(¶ms, "videoId")?; + let rating = arg::(¶ms, "rating")?; + if let Some(tid) = spotify_track_id(&video_id) { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + client + .set_track_saved(tid, matches!(rating, Rating::Like)) + .await + .map_err(spotify_err)?; + return null(); + } + // No YouTube rating to set (a guest, a SoundCloud or local track): the heart + // lands in the device's Liked Songs instead, which the library lists. + let no_youtube = radio::is_radio_id(&video_id) + || local::is_local_song(&video_id) + || is_sc_id(&video_id) + || !st.it.is_logged_in(); + if no_youtube { + st.rate_locally(&video_id, matches!(rating, Rating::Like)) + .await + .map_err(err)?; + return null(); + } + let client = require_login(st).map_err(err)?; + st.rate_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + st.it.rate(client, &video_id, rating).await.map_err(err)?; + null() + } + "set_album_saved" => { + let playlist_id = arg::(¶ms, "playlistId")?; + let saved = arg::(¶ms, "saved")?; + if let Some(aid) = playlist_id.strip_prefix("spotify:album:") { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + client.set_album_saved(aid, saved).await.map_err(spotify_err)?; + return null(); + } + let client = require_login(st).map_err(err)?; + st.it.like_playlist(client, &playlist_id, saved).await.map_err(err)?; + null() + } + "add_to_playlist" => { + let playlist_id = arg::(¶ms, "playlistId")?; + let video_id = arg::(¶ms, "videoId")?; + if let Some(pid) = playlist_id.strip_prefix("spotify:playlist:") { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let tid = spotify_track_id(&video_id) + .ok_or_else(|| spotify_err("Not a Spotify track."))?; + if pid == "liked" { + client.set_track_saved(tid, true).await.map_err(spotify_err)?; + } else { + client.add_track_to_playlist(pid, tid).await.map_err(spotify_err)?; + } + return null(); + } + if radio::is_radio_id(&video_id) { + return Err(err("Live radio stations cannot be added to song playlists.")); + } + let client = editable_playlist(st, &playlist_id).map_err(err)?; + let added = + st.it.playlist_add(client, &playlist_id, &video_id).await.map_err(err)?; + st.db.add_playlist_track(&playlist_id, &video_id); + ok(added) + } + "add_to_local_playlist" => { + let playlist_id = arg::(¶ms, "playlistId")?; + let item = arg::(¶ms, "item")?; + if !is_local_playlist_id(&playlist_id) { + return Err(err("not a device playlist")); + } + if radio::is_radio_id(&item.video_id) { + return Err(err("Live radio stations cannot be added to song playlists.")); + } + let item = shed_queue_context(item); + let json = serde_json::to_string(&item).map_err(err)?; + let added = st + .db + .add_local_playlist_track(&playlist_id, &item.video_id, &json) + .map_err(|e| err(format!("device playlist: {e}")))?; + if added { + st.emit("library-changed", json!({ "id": playlist_id })); + } + ok(added) + } + "remove_from_playlist" => { + let playlist_id = arg::(¶ms, "playlistId")?; + let video_id = arg::(¶ms, "videoId")?; + if let Some(pid) = playlist_id.strip_prefix("spotify:playlist:") { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let tid = spotify_track_id(&video_id) + .ok_or_else(|| spotify_err("Not a Spotify track."))?; + if pid == "liked" { + client.set_track_saved(tid, false).await.map_err(spotify_err)?; + } else { + client.remove_track_from_playlist(pid, tid).await.map_err(spotify_err)?; + } + return null(); + } + let set_video_id = arg::(¶ms, "setVideoId")?; + if is_local_playlist_id(&playlist_id) { + st.db + .remove_local_playlist_track(&playlist_id, &video_id) + .map_err(|e| err(format!("device playlist: {e}")))?; + st.emit("library-changed", json!({ "id": playlist_id })); + return null(); + } + let client = editable_playlist(st, &playlist_id).map_err(err)?; + st.it + .playlist_remove(client, &playlist_id, &video_id, &set_video_id) + .await + .map_err(err)?; + st.db.remove_playlist_track(&playlist_id, &video_id); + null() + } + "create_playlist" => { + let title = arg::(¶ms, "title")?; + let title = title.trim(); + if title.is_empty() { + return Err(err("Playlist name cannot be empty.")); + } + let title: String = title.chars().take(150).collect(); + if st.spotify.browsing_spotify().await { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let id = client.create_playlist(&title).await.map_err(spotify_err)?; + return ok(format!("spotify:playlist:{id}")); + } + if st.it.is_logged_in() { + let client = require_login(st).map_err(err)?; + return ok(st.it.create_playlist(client, &title).await.map_err(err)?); + } + // Internal namespace, never sent to YouTube. A random suffix (seeded by the OS via + // `RandomState`) avoids collisions between rapid creates; `rand` is not a dependency. + let rnd = { + use std::hash::{BuildHasher, Hasher}; + std::collections::hash_map::RandomState::new().build_hasher().finish() + }; + let id = format!("{LOCAL_PLAYLIST_PREFIX}{}-{rnd:016x}", now_secs()); + st.db + .create_local_playlist(&id, &title) + .map_err(|e| err(format!("device playlist: {e}")))?; + ok(id) + } + "edit_playlist_details" => { + let playlist_id = arg::(¶ms, "playlistId")?; + if let Some(pid) = playlist_id.strip_prefix("spotify:playlist:") { + spotify_edit_playlist(st, ¶ms, pid).await + } else { + edit_playlist_details(st, ¶ms).await + } + } + "set_playlist_cover" => set_playlist_cover(st, ¶ms).await, + "set_playlist_sort" => { + let playlist_id = arg::(¶ms, "playlistId")?; + let sort = arg::(¶ms, "sort")?; + let client = editable_playlist(st, &playlist_id).map_err(err)?; + st.it.playlist_set_sort(client, &playlist_id, sort).await.map_err(err)?; + null() + } + "delete_playlist" => { + let playlist_id = arg::(¶ms, "playlistId")?; + if let Some(pid) = playlist_id.strip_prefix("spotify:playlist:") { + if pid == "liked" { + return Err(spotify_err("The liked songs playlist cannot be removed.")); + } + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + client.delete_playlist(pid).await.map_err(spotify_err)?; + return null(); + } + if is_local_playlist_id(&playlist_id) { + st.db + .delete_local_playlist(&playlist_id) + .map_err(|e| err(format!("device playlist: {e}")))?; + st.db.delete_setting(&cover_key(&playlist_id)); + st.db.delete_setting(&synced_key(&playlist_id)); + return null(); + } + let client = editable_playlist(st, &playlist_id).map_err(err)?; + st.it.delete_playlist(client, &playlist_id).await.map_err(err)?; + st.db.forget_playlist(&playlist_id); + null() + } + + // --- Listen Together ------------------------------------------------------------- + "lt_get_state" => ok(st.lt.snapshot().await), + "lt_set_server_url" => { + let url = normalize_lt_server_url(&arg::(¶ms, "url")?).map_err(err)?; + st.db.set_setting("lt_server_url", &url); + st.lt.set_server_url(url).await; + null() + } + "lt_create_room" => { + st.lt.create_room(arg::(¶ms, "username")?).await; + null() + } + "lt_join_room" => { + st.lt + .join_room(arg::(¶ms, "code")?, arg::(¶ms, "username")?) + .await; + null() + } + "lt_leave" => { + st.lt.leave().await; + null() + } + "lt_approve_join" => { + st.lt.approve_join(arg::(¶ms, "userId")?).await; + null() + } + "lt_reject_join" => { + st.lt.reject_join(arg::(¶ms, "userId")?).await; + null() + } + "lt_kick" => { + st.lt.kick(arg::(¶ms, "userId")?).await; + null() + } + "lt_transfer_host" => { + st.lt.transfer_host(arg::(¶ms, "userId")?).await; + null() + } + "lt_suggest" => { + st.lt.suggest(song_to_track(&arg::(¶ms, "item")?)).await; + null() + } + "lt_approve_suggestion" => { + if let Some(track) = st.lt.approve_suggestion(arg::(¶ms, "id")?).await { + st.clone().lt_enqueue_track(track).await; + } + null() + } + "lt_reject_suggestion" => { + st.lt.reject_suggestion(arg::(¶ms, "id")?).await; + null() + } + "lt_request_sync" => { + st.lt.request_sync().await; + null() + } + + // --- lyrics + misc --------------------------------------------------------------- + "get_lyrics" => { + let video_id = arg::(¶ms, "videoId")?; + if let Some(tid) = spotify_track_id(&video_id) { + let Some(client) = st.spotify.client().await else { + return null(); + }; + let lyrics = client.lyrics(tid).await.map_err(spotify_err)?; + return ok(lyrics.as_ref().map(spotify_bridge::lyrics_to_lyrics)); + } + if radio::is_radio_id(&video_id) { + return null(); + } + ok(ryotunes_core::lyrics::get_lyrics( + st, + ryotunes_core::lyrics::LyricsRequest { + video_id, + title: arg::(¶ms, "title")?, + artists: arg::(¶ms, "artists")?, + album: arg::>(¶ms, "album")?, + duration: arg::>(¶ms, "duration")?, + }, + ) + .await) + } + "open_external" => { + ryotunes_core::lastfm::open_browser(&arg::(¶ms, "url")?) + .map_err(err)?; + null() + } + "lastfm_connect" => { + ryotunes_core::lastfm::connect(st.clone()).await.map_err(err)?; + null() + } + "lastfm_disconnect" => { + ryotunes_core::lastfm::disconnect(st); + null() + } + "lastfm_status" => ok(ryotunes_core::lastfm::status(st)), + + // --- downloads ------------------------------------------------------------------- + "get_download_settings" => ok(self.downloads.settings()), + "set_download_settings" => { + ok(self.downloads.set_settings(arg(¶ms, "settings")?).map_err(err)?) + } + "get_downloads" => Ok(self.downloads.snapshot()), + "enqueue_download" => { + let video_id = arg::(¶ms, "videoId")?; + let title = arg::(¶ms, "title")?; + let artists = arg::>(¶ms, "artists")?.unwrap_or_default(); + let thumbnail = arg::>(¶ms, "thumbnail")?.unwrap_or_default(); + ok(self.downloads.enqueue(video_id, title, artists, thumbnail).map_err(err)?) + } + "enqueue_collection" => { + let entries = arg::>(¶ms, "entries")?; + ok(self.downloads.enqueue_collection(entries).map_err(err)?) + } + "cancel_download" => { + self.downloads.cancel(&arg::(¶ms, "id")?).map_err(err)?; + null() + } + "retry_download" => { + ok(self.downloads.retry(&arg::(¶ms, "id")?).map_err(err)?) + } + "soundcloud_sign_in" => { + // One flow at a time; a second click is a no-op while the window is open. + if SC_SIGNING_IN + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return ok(json!({ "started": false })); + } + let task = st.clone(); + tokio::spawn(async move { + let _guard = SoundcloudSignInGuard; + // Already signed in in the browser? Import it right now — no round trip. + if let Some(auth) = tokio::task::spawn_blocking(crate::browser_auth::import_any) + .await + .ok() + .flatten() + { + complete_soundcloud_sign_in(&task, auth).await; + return; + } + // Otherwise hand the real sign-in to the system default browser, where + // hCaptcha and the Google/Facebook/Apple popups actually work, and watch the + // browser's cookie store for the session to appear. + if !crate::browser_auth::open_in_default_browser() { + task.emit( + "soundcloud-auth", + json!({ + "state": "error", + "message": "Could not open your browser. Sign in at soundcloud.com, then press the button again.", + }), + ); + return; + } + task.emit("soundcloud-auth", json!({ "state": "waiting" })); + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(SC_SIGN_IN_TIMEOUT_SECS); + loop { + tokio::time::sleep(std::time::Duration::from_secs(SC_SIGN_IN_POLL_SECS)) + .await; + if tokio::time::Instant::now() >= deadline { + task.emit( + "soundcloud-auth", + json!({ + "state": "expired", + "message": "No SoundCloud sign-in was completed in the browser.", + }), + ); + return; + } + if let Some(auth) = + tokio::task::spawn_blocking(crate::browser_auth::import_any) + .await + .ok() + .flatten() + { + complete_soundcloud_sign_in(&task, auth).await; + return; + } + } + }); + ok(json!({ "started": true })) + } + "soundcloud_sign_out" => { + st.soundcloud.set_auth(None); + st.db.delete_setting(ryotunes_core::state::SOUNDCLOUD_AUTH_KEY); + st.emit("soundcloud-auth", json!({ "state": "signed_out" })); + ok(json!({ "signedIn": false })) + } + "clear_download_history" => { + self.downloads.clear_history().map_err(err)?; + null() + } + "open_download" => { + self.downloads.open(&arg::(¶ms, "id")?).map_err(err)?; + null() + } + "open_download_folder" => { + self.downloads.open_folder().map_err(err)?; + null() + } + + // --- diagnostics --------------------------------------------------------------- + "get_logs" => { + let level = + arg::>(¶ms, "level")?.unwrap_or_else(|| "warn".into()); + let source = + arg::>(¶ms, "source")?.unwrap_or_else(|| "all".into()); + let query = arg::>(¶ms, "query")?.unwrap_or_default(); + let limit = arg::>(¶ms, "limit")?.unwrap_or(500).clamp(1, 2000); + Ok(crate::diagnostics::snapshot(&level, &source, &query, limit)) + } + "log_client_error" => { + // The client's own failures (including ones it buffered while the daemon was + // down) join the same record, tagged `client` so the UI can filter them apart. + let message = arg::(¶ms, "message")?; + let target = + arg::>(¶ms, "target")?.unwrap_or_else(|| "client".into()); + let level = + arg::>(¶ms, "level")?.unwrap_or_else(|| "error".into()); + let level = if level == "warn" { "warn" } else { "error" }; + crate::diagnostics::record(level, "client", &target, &message); + null() + } + "open_logs_folder" => { + crate::diagnostics::open_logs_folder().map_err(err)?; + null() + } + + // --- client-side / not ported ---------------------------------------------------- + "frontend_ready" | "open_mini" | "close_mini" | "login_webview" => Err(ErrorBody { + code: "client_side".into(), + message: format!("{method} is handled by the client"), + }), + "ryoku_theme_tokens" => Err(ErrorBody { + code: "client_side".into(), + message: "the client reads the Ryoku palette singletons directly".into(), + }), + + _ => Err(ErrorBody { code: "unknown_method".into(), message: method.into() }), + } + } +} + +// --- Spotify command bodies ------------------------------------------------------------------- + +/// The `{signedIn, stored, name, premium}` block for `spotify_status` and the subscribe snapshot. +/// A signed-in session implies Premium (the crate gates both sign-in and restore on it). +async fn spotify_status_json(st: &Arc) -> Value { + let signed_in = st.spotify.status().await; + let name = if signed_in { spotify_name(st).await } else { None }; + let premium = signed_in.then_some(true); + json!({ + "signedIn": signed_in, + "stored": st.spotify.stored(), + "name": name, + "premium": premium, + }) +} + +/// The signed-in profile's display name, fetched fresh. `None` when signed out or unreachable. +async fn spotify_name(st: &Arc) -> Option { + let client = st.spotify.client().await?; + client.profile().await.ok().map(|profile| profile.display_name) +} + +async fn spotify_search_all(st: &Arc, query: &str) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let results = client.search(query).await.map_err(spotify_err)?; + ok(spotify_bridge::search_all(&results)) +} + +async fn spotify_search_page(st: &Arc, query: &str) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let results = client.search(query).await.map_err(spotify_err)?; + ok(json!({ "items": spotify_bridge::search_songs(&results), "continuation": null })) +} + +async fn spotify_search_cards( + st: &Arc, + query: &str, + category: &str, +) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let results = client.search(query).await.map_err(spotify_err)?; + ok(spotify_bridge::search_cards(&results, category)) +} + +async fn spotify_library(st: &Arc) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let playlists = client.playlists(200).await.map_err(spotify_err)?; + let mut items = vec![spotify_bridge::liked_card()]; + items.extend(playlists.iter().map(spotify_bridge::playlist_to_card)); + ok(items) +} + +async fn spotify_library_albums(st: &Arc) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let albums = client.saved_albums(200).await.map_err(spotify_err)?; + ok(albums.iter().map(spotify_bridge::album_to_card).collect::>()) +} + +async fn spotify_library_artists(st: &Arc) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let artists = client.saved_artists(200).await.map_err(spotify_err)?; + ok(artists.iter().map(spotify_bridge::saved_artist_to_card).collect::>()) +} + +/// `spotify:playlist:liked` pages every saved track; any other id is a real Spotify playlist. +async fn spotify_playlist(st: &Arc, pid: &str) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + if pid == "liked" { + let mut tracks = Vec::new(); + let mut page = 0u32; + loop { + let batch = client.liked(page).await.map_err(spotify_err)?; + let full = batch.len() >= SPOTIFY_LIKED_PAGE; + tracks.extend(batch); + if !full { + break; + } + page += 1; + } + ok(spotify_bridge::liked_page(&tracks)) + } else { + let detail = client.playlist(pid).await.map_err(spotify_err)?; + ok(spotify_bridge::playlist_page(&detail)) + } +} + +async fn spotify_album(st: &Arc, id: &str) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let detail = client.album(id).await.map_err(spotify_err)?; + ok(spotify_bridge::album_page(&detail)) +} + +async fn spotify_artist(st: &Arc, id: &str) -> Result { + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + let artist = client.artist(id).await.map_err(spotify_err)?; + ok(spotify_bridge::artist_page(id, &artist)) +} + +/// Rename / set-public for a Spotify playlist. Description is dropped (the crate has no setter); +/// the liked-songs view is not editable. +async fn spotify_edit_playlist( + st: &Arc, + params: &Value, + pid: &str, +) -> Result { + if pid == "liked" { + return Err(spotify_err("The liked songs playlist cannot be edited.")); + } + let client = st.spotify.client().await.ok_or_else(spotify_signed_out)?; + if let Some(name) = arg::>(params, "name")? { + let name = name.trim(); + if name.is_empty() { + return Err(err("Playlist name cannot be empty.")); + } + let name: String = name.chars().take(150).collect(); + client.rename_playlist(pid, &name).await.map_err(spotify_err)?; + } + if let Some(public) = arg::>(params, "public")? { + client.set_playlist_public(pid, public).await.map_err(spotify_err)?; + } + null() +} + +// --- SoundCloud command bodies ---------------------------------------------------------------- + +/// The merged Home: YouTube Music's own feed (works signed out — the default), with the +/// signed-in providers' shelves woven in. Spotify contributes its made-for-you rows whenever +/// a Premium session exists; a signed-in SoundCloud contributes its personal rows (playlists, +/// likes, following) ahead of its discover rows. Every shelf is the same HomePage `Section` +/// shape, and every card id (`spotify:…`, `sc:…`) already round-trips through the by-prefix +/// dispatch in get_playlist/get_album/get_artist, so the client renders and navigates the +/// mix without knowing where each row came from. One provider failing never blanks the +/// page: its shelves are simply absent, and so is a dead YouTube spine — offline, the merged +/// home still shows what the signed-in providers hold. +async fn home_merged(st: &Arc) -> Result { + let mut spine = st.home(None).await.unwrap_or(innertube::HomePage { + chips: Vec::new(), + sections: Vec::new(), + continuation: None, + }); + let mut extra: Vec = Vec::new(); + + // SoundCloud: personal shelves first when signed in, then the discover rows everyone gets. + if let Some(mut sc) = soundcloud_home_sections(st).await { + extra.append(&mut sc); + } + + // Spotify: whenever a Premium session exists — the merged home aggregates every + // signed-in provider, regardless of which catalogue the selector points at for + // Library/Search. A session that died since startup recovers once from the cache. + if let Some(client) = st.spotify.client_or_recover().await { + if let Ok(feed) = client.home().await { + extra.extend(spotify_bridge::home_page(&feed).sections); + } + } + + if !extra.is_empty() { + // Provider rows lead when YouTube itself has nothing to show (a signed-out first run + // with a dead spine); otherwise they follow the spine's opening rows so the default + // experience is unchanged. + let at = spine.sections.len().min(3); + spine.sections.splice(at..at, extra); + } + ok(spine) +} + +/// SoundCloud's contribution to the merged home: the signed-in account's own rows first +/// (playlists / likes / following), then the public discover shelves. A dead token clears +/// itself so the personal rows stop demanding re-auth on every open. +async fn soundcloud_home_sections(st: &Arc) -> Option> { + let mut sections = Vec::new(); + if st.soundcloud.signed_in() { + match ( + st.soundcloud.my_playlists().await, + st.soundcloud.my_likes().await, + st.soundcloud.my_followings().await, + ) { + (Ok(playlists), Ok(likes), Ok(followings)) => { + sections = soundcloud_bridge::personal_home(&playlists, &likes, &followings); + } + // A 401/403 from the personal calls means the session is gone, not thin: forget + // it and say so, exactly like the Spotify restore_failed announcement. Any other + // error (a network blip) keeps the token — clearing it would turn one failed + // page load into a sign-out. + (a, b, c) => { + let is_dead = |e: &ryotunes_soundcloud::Error| { + matches!(e, ryotunes_soundcloud::Error::Api { status: 401 | 403, .. }) + }; + // The three calls differ in payload type, so test their errors one by one. + let dead = a.as_ref().err().is_some_and(is_dead) + || b.as_ref().err().is_some_and(is_dead) + || c.as_ref().err().is_some_and(is_dead); + if dead { + st.soundcloud.set_auth(None); + st.db.delete_setting(ryotunes_core::state::SOUNDCLOUD_AUTH_KEY); + st.emit( + "soundcloud-auth", + json!({ + "state": "restore_failed", + "message": "Your SoundCloud session expired. Sign in again to \ + see your playlists and likes.", + }), + ); + } + } + } + } + match st.soundcloud.discover().await { + Ok(selections) => { + sections.extend(soundcloud_bridge::discover_home(&selections).sections); + Some(sections) + } + // Discover is public; failing it is a network problem, and the personal rows (if any) + // still ride. + Err(_) if !sections.is_empty() => Some(sections), + Err(_) => None, + } +} + +async fn soundcloud_search_all(st: &Arc, query: &str) -> Result { + let results = st.soundcloud.search(query).await.map_err(soundcloud_err)?; + ok(soundcloud_bridge::search_all(&results)) +} + +async fn soundcloud_search_page(st: &Arc, query: &str) -> Result { + let results = st.soundcloud.search(query).await.map_err(soundcloud_err)?; + ok(json!({ "items": soundcloud_bridge::search_songs(&results), "continuation": null })) +} + +async fn soundcloud_search_cards( + st: &Arc, + query: &str, + category: &str, +) -> Result { + let results = st.soundcloud.search(query).await.map_err(soundcloud_err)?; + ok(soundcloud_bridge::search_cards(&results, category)) +} + +async fn soundcloud_album(st: &Arc, id: u64) -> Result { + let detail = st.soundcloud.playlist(id).await.map_err(soundcloud_err)?; + ok(soundcloud_bridge::album_page(&detail)) +} + +async fn soundcloud_playlist(st: &Arc, id: u64) -> Result { + let detail = st.soundcloud.playlist(id).await.map_err(soundcloud_err)?; + ok(soundcloud_bridge::playlist_page(&detail)) +} + +async fn soundcloud_system_playlist( + st: &Arc, + permalink: &str, +) -> Result { + let detail = st.soundcloud.system_playlist(permalink).await.map_err(soundcloud_err)?; + ok(soundcloud_bridge::system_playlist_page(&detail)) +} + +/// The Orange/SoundCloud artist page: the user profile plus their tracks, albums, playlists and +/// likes, fetched concurrently. A missing sub-list degrades to empty rather than failing the page. +async fn soundcloud_artist(st: &Arc, id: u64) -> Result { + let sc = &st.soundcloud; + let user = sc.user(id).await.map_err(soundcloud_err)?; + let (tracks, albums, playlists, likes) = tokio::join!( + sc.user_tracks(id, 0), + sc.user_albums(id), + sc.user_playlists(id), + sc.user_likes(id), + ); + let tracks = tracks.map(|page| page.items).unwrap_or_default(); + let page = soundcloud_bridge::artist_page( + &user, + &tracks, + &albums.unwrap_or_default(), + &playlists.unwrap_or_default(), + &likes.unwrap_or_default(), + ); + ok(page) +} + +/// The waveform for a SoundCloud track: 240 peak samples (0..=100) for the seek bar. +async fn soundcloud_waveform(st: &Arc, id: u64) -> Result { + let track = st.soundcloud.track(id).await.map_err(soundcloud_err)?; + let samples = st.soundcloud.waveform(&track).await.map_err(soundcloud_err)?; + ok(json!({ "samples": samples })) +} + +// --- ported command bodies that are too large for a match arm --------------------------------- + +fn set_setting(st: &Arc, params: &Value) -> Result { + let key = arg::(params, "key")?; + let value = arg::(params, "value")?; + if !UI_SETTINGS.contains(&key.as_str()) { + return Err(err(format!("unknown setting: {key}"))); + } + let value = normalize_ui_setting(&key, &value).map_err(err)?; + // Autostart is an OS/desktop-file concern the daemon does not manage; it persists the value + // like any other so the setting round-trips, but performs no launcher registration. + if key == "discord_presence_name" { + let name = ryotunes_core::discord::normalize_presence_name(&value).map_err(err)?; + st.db.set_setting(&key, &name); + st.set_discord_name(name); + return null(); + } + st.db.set_setting(&key, &value); + if key == "discord_rpc" { + st.set_discord_enabled(value == "true"); + } + if key == "low_resource_mode" { + st.set_low_resource_mode(value == "true"); + } + if key == "lyrics_boidu" { + st.db.clear_lyrics_cache(); + } + null() +} + +async fn get_library(st: &Arc) -> Result { + let client = metadata_client(st).map_err(err)?; + let mut items = if st.it.is_logged_in() { + st.it.library_playlists(client).await.map_err(err)? + } else { + Vec::new() + }; + let songs = on_repeat_songs(st); + if !songs.is_empty() { + items.insert( + 0, + BrowseItem { + kind: "playlist", + id: ON_REPEAT_ID.into(), + title: "On Repeat".into(), + subtitle: Some(format!("{} songs", songs.len())), + thumbnail: None, + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + }, + ); + } + let recent = recent_songs(st); + if !recent.is_empty() { + items.insert( + usize::from(!songs.is_empty()), + smart_playlist_card(RECENTLY_PLAYED_ID, "Recently Played", &recent), + ); + } + let rediscover = rediscover_songs(st); + if !rediscover.is_empty() { + let at = usize::from(!songs.is_empty()) + usize::from(!recent.is_empty()); + items.insert(at, smart_playlist_card(REDISCOVER_ID, "Rediscover", &rediscover)); + } + let device: Vec = + st.db.local_playlists().into_iter().map(|row| local_playlist_card(st, row)).collect(); + let smart_count = usize::from(!songs.is_empty()) + + usize::from(!recent.is_empty()) + + usize::from(!rediscover.is_empty()); + items.splice(smart_count..smart_count, device); + for item in &mut items { + if let Some(cover) = custom_cover(st, &item.id) { + item.thumbnail = Some(cover); + } + } + ok(items) +} + +async fn get_playlist(st: &Arc, params: &Value) -> Result { + let id = arg::(params, "id")?; + if let Some(pid) = id.strip_prefix("spotify:playlist:") { + return spotify_playlist(st, pid).await; + } + if let Some(permalink) = sc_system_id(&id) { + return soundcloud_system_playlist(st, permalink).await; + } + if let Some(pid) = sc_playlist_id(&id) { + return soundcloud_playlist(st, pid).await; + } + let sort = arg::>(params, "sort")?; + let desc = arg::>(params, "desc")?; + if is_local_playlist_id(&id) { + // Liked Songs exists from the first read, so the library's Songs tab renders empty + // rather than erroring before the first heart. + if id == LIKED_SONGS_ID && st.db.local_playlist(&id).is_none() { + st.db + .create_local_playlist(LIKED_SONGS_ID, LIKED_SONGS_TITLE) + .map_err(|e| err(format!("device playlist: {e}")))?; + } + return ok(local_playlist_page(st, &id) + .ok_or_else(|| err("That device playlist no longer exists."))?); + } + if id == ON_REPEAT_ID { + let items = on_repeat_songs(st); + return ok(PlaylistPage { + title: Some("On Repeat".into()), + subtitle: Some(format!("{} songs you've played most this month", items.len())), + thumbnail: None, + description: None, + privacy: None, + cover: None, + items, + continuation: None, + owned: false, + collaborative: false, + sort_menu: None, + }); + } + if id == RECENTLY_PLAYED_ID { + let items = recent_songs(st); + return ok(smart_playlist_page( + "Recently Played", + format!("{} songs you've returned to this week", items.len()), + items, + )); + } + if id == REDISCOVER_ID { + let items = rediscover_songs(st); + return ok(smart_playlist_page( + "Rediscover", + format!("{} songs ready for another listen", items.len()), + items, + )); + } + let client = metadata_client(st).map_err(err)?; + let sort = sort.map(|s| (s, desc.unwrap_or(false))); + let mut page = st.it.playlist(client, &id, sort).await.map_err(err)?; + page.cover = custom_cover(st, &id); + ok(page) +} + +async fn sync_playlist_index(st: &Arc) -> Result { + const LIKED_MUSIC_ID: &str = "VLLM"; + const PLAYLIST_INDEX_TTL_SECS: i64 = 6 * 3600; + const PLAYLIST_INDEX_MAX_PAGES: usize = 50; + + if !st.it.is_logged_in() { + st.db.clear_playlist_index(); + return ok(st.db.playlist_memberships()); + } + let fresh_until = st + .db + .get_setting("playlist_index_synced_at") + .and_then(|at| at.parse::().ok()) + .map(|at| at + PLAYLIST_INDEX_TTL_SECS); + if fresh_until.is_some_and(|until| now_secs() < until) { + return ok(st.db.playlist_memberships()); + } + let client = metadata_client(st).map_err(err)?; + let library = st.it.library_playlists(client).await.map_err(err)?; + if library.is_empty() { + return ok(st.db.playlist_memberships()); + } + let mut indexed: Vec = Vec::new(); + for item in library { + if is_smart_playlist_id(&item.id) || item.id == LIKED_MUSIC_ID { + continue; + } + let Ok(page) = st.it.playlist(client, &item.id, None).await else { + indexed.push(item.id); + continue; + }; + if !page.owned && !page.collaborative { + continue; + } + let mut video_ids: Vec = page.items.into_iter().map(|song| song.video_id).collect(); + let mut token = page.continuation; + for _ in 0..PLAYLIST_INDEX_MAX_PAGES { + let Some(next) = token.take() else { break }; + let Ok(more) = st.it.playlist_continuation(client, &next).await else { break }; + video_ids.extend(more.items.into_iter().map(|song| song.video_id)); + token = more.continuation; + } + st.db.set_playlist_tracks(&item.id, &video_ids); + indexed.push(item.id); + } + st.db.retain_playlists(&indexed); + st.db.set_setting("playlist_index_synced_at", &now_secs().to_string()); + ok(st.db.playlist_memberships()) +} + +fn listening_stats(st: &Arc, params: &Value) -> Result { + use std::collections::HashMap; + let period = arg::(params, "period")?; + let seconds = match period.as_str() { + "day" => 24 * 60 * 60, + "month" => ON_REPEAT_WINDOW_SECS, + _ => 7 * 24 * 60 * 60, + }; + let rows = st.db.play_rows(now_secs() - seconds); + let mut artists: HashMap = HashMap::new(); + let mut tracks: HashMap = HashMap::new(); + let mut known_duration_secs = 0u64; + for json in &rows { + let Ok(song) = serde_json::from_str::(json) else { continue }; + *artists.entry(song.artists.clone()).or_insert(0) += 1; + let entry = tracks.entry(song.video_id.clone()).or_insert(( + song.title.clone(), + song.artists.clone(), + 0, + )); + entry.2 += 1; + if let Some(duration) = song.duration.as_deref().and_then(duration_secs) { + known_duration_secs += duration; + } + } + let mut top_artists: Vec<_> = artists.into_iter().collect(); + top_artists.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + top_artists.truncate(8); + let mut top_tracks: Vec<_> = tracks.into_values().collect(); + top_tracks.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + top_tracks.truncate(8); + ok(json!({ + "period": period, + "plays": rows.len(), + "knownDurationSeconds": known_duration_secs, + "topArtists": top_artists.into_iter().map(|(name, plays)| json!({"name": name, "plays": plays})).collect::>(), + "topTracks": top_tracks.into_iter().map(|(title, artists, plays)| json!({"title": title, "artists": artists, "plays": plays})).collect::>(), + })) +} + +async fn edit_playlist_details(st: &Arc, params: &Value) -> Result { + let playlist_id = arg::(params, "playlistId")?; + let name = arg::>(params, "name")?; + let description = arg::>(params, "description")?; + let public = arg::>(params, "public")?; + if is_local_playlist_id(&playlist_id) { + if description.is_some() || public.is_some() { + return Err(err("Device playlists only store a name and local artwork.")); + } + if let Some(name) = name { + let name = name.trim(); + if name.is_empty() { + return Err(err("Playlist name cannot be empty.")); + } + let name: String = name.chars().take(150).collect(); + st.db + .rename_local_playlist(&playlist_id, &name) + .map_err(|e| err(format!("device playlist: {e}")))?; + } + return null(); + } + let client = editable_playlist(st, &playlist_id).map_err(err)?; + let privacy = public.map(|p| if p { "PUBLIC" } else { "PRIVATE" }); + st.it + .playlist_edit_details( + client, + &playlist_id, + name.as_deref(), + description.as_deref(), + privacy, + ) + .await + .map_err(err)?; + null() +} + +/// What the UI needs to draw after a cover changed: where the local copy is, and (on a removal) +/// the thumbnail YouTube rebuilt in its place. +#[derive(serde::Serialize)] +struct CoverResult { + cover: Option, + thumbnail: Option, +} + +async fn set_playlist_cover(st: &Arc, params: &Value) -> Result { + use std::io::Read; + const MAX_BYTES: u64 = 8 * 1024 * 1024; + const PNG_MAGIC: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + + let playlist_id = arg::(params, "playlistId")?; + let pick = arg::(params, "pick")?; + let key = cover_key(&playlist_id); + let stored = st.db.get_setting(&key); + + if !pick { + let thumbnail = match clear_cover_on_youtube(st, &playlist_id).await { + Ok(t) => { + st.db.delete_setting(&synced_key(&playlist_id)); + t + } + Err(e) => { + tracing::warn!(playlist_id, error = %e, "custom cover not cleared on YouTube Music"); + if st.db.get_setting(&synced_key(&playlist_id)).is_some() { + st.emit( + "cover-error", + json!({ "message": "Removed here, but YouTube Music kept its copy." }), + ); + } + None + } + }; + st.db.delete_setting(&key); + if let Some(old) = stored { + let _ = std::fs::remove_file(old); + } + // Device playlists never had a YouTube copy to fall back on; hand back the automatic + // collage/first-cover so the UI redraws the default the instant the override is removed. + let thumbnail = if is_local_playlist_id(&playlist_id) { + thumbnail.or_else(|| local_playlist_artwork(st, &playlist_id)) + } else { + thumbnail + }; + st.emit("library-changed", json!({ "id": playlist_id })); + return ok(CoverResult { cover: None, thumbnail }); + } + + // The renderer never supplies a filesystem path in the Tauri app; over the socket the client + // has already run its own picker and hands us the chosen file. + let src = std::path::PathBuf::from(arg::(params, "path")?); + let ext = src.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + if !matches!(ext.as_str(), "jpg" | "jpeg" | "png") { + return Err(err("Pick a JPEG or PNG image.")); + } + let meta = src.metadata().map_err(|_| err("That image cannot be read."))?; + if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_BYTES { + return Err(err("Artwork must be a readable JPEG/PNG up to 8 MB.")); + } + let mut head = [0u8; 8]; + let mut file = std::fs::File::open(&src).map_err(err)?; + let read = file.read(&mut head).map_err(err)?; + let is_png = read >= PNG_MAGIC.len() && &head[..PNG_MAGIC.len()] == PNG_MAGIC; + let is_jpeg = read >= 3 && head[..3] == [0xFF, 0xD8, 0xFF]; + if !is_png && !is_jpeg { + return Err(err("That file is not a valid JPEG or PNG image.")); + } + let dir = local::covers_dir(&st.paths).join("playlists"); + std::fs::create_dir_all(&dir).map_err(err)?; + let stem: String = playlist_id + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + let stem = if stem.is_empty() { "playlist" } else { stem.as_str() }; + let dest = dir.join(format!("{stem}-{}.{ext}", ryotunes_core::db::now_secs())); + std::fs::copy(&src, &dest).map_err(err)?; + if let Some(old) = stored { + let _ = std::fs::remove_file(old); + } + let dest = dest.to_string_lossy().to_string(); + st.db.set_setting(&key, &dest); + sync_cover(st, &playlist_id, dest.clone()); + st.emit("library-changed", json!({ "id": playlist_id })); + ok(CoverResult { cover: Some(dest), thumbnail: None }) +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlaylistTransfer { + version: u32, + title: String, + items: Vec, +} + +fn export_playlist_file(params: &Value) -> Result { + let title = arg::(params, "title")?; + let items = arg::>(params, "items")?; + if items.len() > 5_000 { + return Err(err("Export is limited to 5,000 tracks.")); + } + if items.iter().any(|item| !portable_song(item)) { + return Err(err( + "Portable playlist export supports YouTube Music tracks only; local files and live radio stay on this device.", + )); + } + let path = + transfer_path(std::path::PathBuf::from(arg::(params, "path")?)).map_err(err)?; + let transfer = PlaylistTransfer { + version: 1, + title: title.trim().chars().take(150).collect(), + items: items.into_iter().map(shed_queue_context).collect(), + }; + let bytes = serde_json::to_vec_pretty(&transfer).map_err(err)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes).map_err(err)?; + std::fs::rename(&tmp, &path) + .or_else(|_| { + let bytes = serde_json::to_vec_pretty(&transfer).map_err(std::io::Error::other)?; + std::fs::write(&path, bytes) + }) + .map_err(err)?; + ok(true) +} + +fn import_playlist_file(params: &Value) -> Result { + let path = + transfer_path(std::path::PathBuf::from(arg::(params, "path")?)).map_err(err)?; + let meta = std::fs::metadata(&path).map_err(err)?; + if meta.len() > 12 * 1024 * 1024 { + return Err(err("That playlist file is too large.")); + } + let bytes = std::fs::read(&path).map_err(err)?; + let mut transfer: PlaylistTransfer = + serde_json::from_slice(&bytes).map_err(|_| err("That is not a Ryotunes playlist file."))?; + if transfer.version != 1 || transfer.items.len() > 5_000 { + return Err(err("That playlist file version or size is not supported.")); + } + if transfer.items.iter().any(|item| !portable_song(item)) { + return Err(err("That playlist contains unsupported or unsafe track metadata.")); + } + transfer.title = transfer.title.trim().chars().filter(|c| !c.is_control()).take(150).collect(); + transfer.items = transfer.items.into_iter().map(shed_queue_context).collect(); + ok(json!({ "title": transfer.title, "items": transfer.items })) +} + +// --- ported private helpers ------------------------------------------------------------------ + +fn metadata_client(state: &Arc) -> Result<&YouTubeClient, String> { + state.clients.get(innertube::METADATA_CLIENT).ok_or_else(|| "metadata client missing".into()) +} + +fn require_login(state: &Arc) -> Result<&YouTubeClient, String> { + if !state.it.is_logged_in() { + return Err("Sign in first to use this.".into()); + } + metadata_client(state) +} + +fn editable_playlist<'a>( + state: &'a Arc, + playlist_id: &str, +) -> Result<&'a YouTubeClient, String> { + if is_smart_playlist_id(playlist_id) { + return Err("Smart playlists build themselves from your listening history.".into()); + } + if playlist_id == "VLLM" { + return Err("Liked Music follows your likes; like the song instead.".into()); + } + require_login(state) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn duration_secs(raw: &str) -> Option { + let mut total = 0u64; + for part in raw.split(':') { + total = total.checked_mul(60)?.checked_add(part.parse::().ok()?)?; + } + Some(total) +} + +/// Strip the queue-slot metadata a play record carried, so a song replayed out of a smart playlist +/// does not wear the session member / autoplay / set-video-id it was played from. +fn shed_queue_context(s: SongItem) -> SongItem { + SongItem { + queued: false, + queued_end: false, + queued_from: None, + queued_by: None, + autoplay: false, + set_video_id: None, + added_by: None, + added_by_avatar: None, + ..s + } +} + +fn parse_play_rows(rows: impl IntoIterator) -> Vec { + rows.into_iter() + .filter_map(|json| serde_json::from_str(&json).ok()) + .map(shed_queue_context) + .collect() +} + +fn recent_songs(state: &Arc) -> Vec { + parse_play_rows( + state + .db + .recent_unique_plays(now_secs() - RECENTLY_PLAYED_WINDOW_SECS, SMART_PLAYLIST_LIMIT), + ) +} + +fn rediscover_songs(state: &Arc) -> Vec { + let now = now_secs(); + parse_play_rows(state.db.rediscover_plays( + now - ON_REPEAT_WINDOW_SECS, + now - REDISCOVER_OLDER_THAN_SECS, + SMART_PLAYLIST_LIMIT, + )) +} + +fn on_repeat_songs(state: &Arc) -> Vec { + let since = now_secs() - ON_REPEAT_WINDOW_SECS; + state + .db + .top_plays(since, ON_REPEAT_LIMIT) + .into_iter() + .filter_map(|(json, _plays)| serde_json::from_str(&json).ok()) + .map(shed_queue_context) + .collect() +} + +fn smart_playlist_card(id: &str, title: &str, songs: &[SongItem]) -> BrowseItem { + BrowseItem { + kind: "playlist", + id: id.into(), + title: title.into(), + subtitle: Some(format!("{} songs", songs.len())), + thumbnail: songs.iter().find_map(|s| s.thumbnail.clone()), + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +fn smart_playlist_page(title: &str, subtitle: String, items: Vec) -> PlaylistPage { + PlaylistPage { + title: Some(title.into()), + subtitle: Some(subtitle), + thumbnail: items.iter().find_map(|s| s.thumbnail.clone()), + description: None, + privacy: None, + cover: None, + items, + continuation: None, + owned: false, + collaborative: false, + sort_menu: None, + } +} + +/// The client-only marker that packs a device playlist's four-cover mosaic into one thumbnail +/// string. The shared native `Artwork.qml` splits on this prefix and renders the JSON array as a +/// 2x2 grid; it is never a real URL, never sent to a provider, and never fetched over the network. +const COLLAGE_PREFIX: &str = "ryotunes-collage:"; + +/// Automatic artwork for a device playlist from its songs' thumbnails, taken in playlist order. +/// Exactly four distinct non-empty covers become a 2x2 collage marker; one to three collapse to +/// the first cover; none yields nothing. This is the single selection rule shared by the library +/// card and the playlist page. +fn auto_playlist_artwork(thumbnails: I) -> Option +where + I: IntoIterator, + S: AsRef, +{ + let mut covers: Vec = Vec::new(); + for thumb in thumbnails { + let thumb = thumb.as_ref().trim(); + if thumb.is_empty() || covers.iter().any(|c| c == thumb) { + continue; + } + covers.push(thumb.to_owned()); + if covers.len() == 4 { + break; + } + } + match covers.len() { + 0 => None, + 4 => Some(format!("{COLLAGE_PREFIX}{}", serde_json::to_string(&covers).ok()?)), + _ => covers.into_iter().next(), + } +} + +/// [`auto_playlist_artwork`] for a stored device playlist, read straight from the database so a +/// card or a cover reset reflects the current tracks without deserializing every song. +fn local_playlist_artwork(state: &Arc, id: &str) -> Option { + auto_playlist_artwork(state.db.local_playlist_track_thumbnails(id)) +} + +fn local_playlist_card(state: &Arc, row: LocalPlaylist) -> BrowseItem { + let thumbnail = local_playlist_artwork(state, &row.id); + BrowseItem { + kind: "playlist", + id: row.id, + title: row.title, + subtitle: Some(format!( + "{} track{} · On this device", + row.track_count, + if row.track_count == 1 { "" } else { "s" } + )), + thumbnail, + duration: None, + artist_runs: Vec::new(), + play_count: None, + is_video: false, + is_upload: false, + explicit: false, + } +} + +fn local_playlist_page(state: &Arc, id: &str) -> Option { + let row = state.db.local_playlist(id)?; + let items: Vec = state + .db + .local_playlist_track_json(id) + .into_iter() + .filter_map(|json| serde_json::from_str::(&json).ok()) + .map(|song| { + let mut song = shed_queue_context(song); + song.set_video_id = Some(song.video_id.clone()); + song + }) + .collect(); + Some(PlaylistPage { + title: Some(row.title), + subtitle: Some(format!( + "{} track{} · Stored on this device", + items.len(), + if items.len() == 1 { "" } else { "s" } + )), + thumbnail: auto_playlist_artwork(items.iter().filter_map(|song| song.thumbnail.as_deref())), + description: None, + privacy: None, + cover: custom_cover(state, id), + items, + continuation: None, + owned: true, + collaborative: false, + sort_menu: None, + }) +} + +fn cover_key(playlist_id: &str) -> String { + format!("playlist_cover:{}", playlist_id.strip_prefix("VL").unwrap_or(playlist_id)) +} + +fn synced_key(playlist_id: &str) -> String { + format!("{}:synced", cover_key(playlist_id)) +} + +fn custom_cover(state: &Arc, playlist_id: &str) -> Option { + let path = state.db.get_setting(&cover_key(playlist_id))?; + std::path::Path::new(&path).is_file().then_some(path) +} + +/// Push a just-saved cover on to YouTube Music behind the caller's back (the local copy is already +/// the answer). A failure is a `cover-error` event, not a rollback. +fn sync_cover(state: &Arc, playlist_id: &str, path: String) { + if is_smart_playlist_id(playlist_id) + || is_local_playlist_id(playlist_id) + || !state.it.is_logged_in() + { + return; + } + let state = Arc::clone(state); + let playlist_id = playlist_id.to_owned(); + tokio::spawn(async move { + let Some(client) = state.clients.get(innertube::METADATA_CLIENT) else { + return; + }; + let result = match std::fs::read(&path) { + Ok(image) => state.it.playlist_set_cover(client, &playlist_id, image).await, + Err(e) => Err(innertube::Error::Other(e.to_string())), + }; + match result { + Ok(()) => state.db.set_setting(&synced_key(&playlist_id), "1"), + Err(e) => { + tracing::warn!(playlist_id, error = %e, "playlist cover didn't reach YouTube Music"); + let message = match e { + innertube::Error::CoverRefused => format!("Artwork saved on this device. {e}"), + e => format!("Artwork saved here, but the upload to YouTube Music failed: {e}"), + }; + state.emit("cover-error", json!({ "message": message })); + } + } + }); +} + +async fn clear_cover_on_youtube( + state: &Arc, + playlist_id: &str, +) -> Result, String> { + if is_smart_playlist_id(playlist_id) + || is_local_playlist_id(playlist_id) + || !state.it.is_logged_in() + { + return Ok(None); + } + let client = metadata_client(state)?; + state.it.playlist_clear_cover(client, playlist_id).await.map_err(|e| e.to_string()) +} + +async fn scan_local(state: &Arc) -> Result { + let covers = local::covers_dir(&state.paths); + let state = state.clone(); + // Disk IO + tag parsing off the async worker threads. The daemon serves no webview assets, so + // the Tauri host's asset-scope allow-listing has no equivalent here. + tokio::task::spawn_blocking(move || local::scan(&state.db, &covers)) + .await + .map_err(|e| e.to_string()) +} + +const MAX_CONFIG_URL: usize = 2_048; + +pub(crate) fn normalize_proxy_setting(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(String::new()); + } + if raw.len() > MAX_CONFIG_URL { + return Err("Proxy URL is too long.".into()); + } + let url = url::Url::parse(raw).map_err(|_| "Enter a valid HTTP or HTTPS proxy URL.")?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("Proxy must use http:// or https:// and include a host.".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err( + "Authenticated proxy URLs are not supported in renderer-visible settings.".into() + ); + } + if url.fragment().is_some() || url.query().is_some() || !matches!(url.path(), "" | "/") { + return Err("Proxy URL must contain only scheme, host and port.".into()); + } + Ok(url.to_string()) +} + +fn normalize_lt_server_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.len() > MAX_CONFIG_URL { + return Err("Listen Together server URL is too long.".into()); + } + let url = url::Url::parse(raw).map_err(|_| "Enter a valid ws:// or wss:// server URL.")?; + if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() { + return Err("Listen Together server must use ws:// or wss:// and include a host.".into()); + } + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err("Listen Together server URLs cannot contain credentials or fragments.".into()); + } + Ok(url.to_string()) +} + +fn known_stream_client(name: &str) -> bool { + name == innertube::MAIN_CLIENT + || innertube::STREAM_FALLBACK_ORDER.contains(&name) + || innertube::UPLOAD_FALLBACK_ORDER.contains(&name) +} + +fn normalize_ui_setting(key: &str, raw: &str) -> Result { + match key { + "volume" => { + let value: i64 = raw.parse().map_err(|_| "Volume must be a number from 0 to 100.")?; + (0..=100) + .contains(&value) + .then(|| value.to_string()) + .ok_or_else(|| "Volume must be between 0 and 100.".into()) + } + "proxy" => normalize_proxy_setting(raw), + "quality" => matches!(raw, "LOW" | "AUTO" | "HIGH") + .then(|| raw.to_owned()) + .ok_or_else(|| "Unknown audio quality.".into()), + "enable_history" | "discord_rpc" | "close_to_tray" | "autostart" | "autoplay" + | "prevent_duplicates" | "lyrics_boidu" | "low_resource_mode" => { + matches!(raw, "true" | "false") + .then(|| raw.to_owned()) + .ok_or_else(|| format!("{key} must be true or false.")) + } + "disabled_stream_clients" => { + let mut names: Vec<&str> = + raw.split(',').map(str::trim).filter(|name| !name.is_empty()).collect(); + if names.iter().any(|name| !known_stream_client(name)) { + return Err("Unknown stream client in disabled-client list.".into()); + } + names.sort_unstable(); + names.dedup(); + Ok(names.join(",")) + } + "ui_scale" => { + let value: i32 = raw.parse().map_err(|_| "Interface scale must be a percentage.")?; + ((80..=140).contains(&value) && value % 10 == 0) + .then(|| value.to_string()) + .ok_or_else(|| "Interface scale must be 80–140% in 10% steps.".into()) + } + "discord_presence_name" => Ok(raw.to_owned()), + _ => Err(format!("unknown setting: {key}")), + } +} + +fn canonical_music_folder(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("Choose a music folder.".into()); + } + let path = std::path::Path::new(raw) + .canonicalize() + .map_err(|_| "That music folder no longer exists or cannot be accessed.")?; + if !path.is_dir() { + return Err("Choose a directory, not a file.".into()); + } + if path.parent().is_none() { + return Err("For safety, choose a music folder instead of the filesystem root.".into()); + } + Ok(path.to_string_lossy().into_owned()) +} + +fn transfer_path(mut path: std::path::PathBuf) -> Result { + match path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) { + None => { + path.set_extension("json"); + Ok(path) + } + Some(ext) if ext == "json" => Ok(path), + _ => Err("Ryotunes playlist files must end in .json".into()), + } +} + +fn portable_song(item: &SongItem) -> bool { + let text_ok = |value: &str, max: usize| { + !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) + }; + let thumbnail_ok = item.thumbnail.as_deref().is_none_or(|raw| { + raw.len() <= 4_096 + && url::Url::parse(raw).ok().is_some_and(|url| { + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + }) + }); + !local::is_local_song(&item.video_id) + && !radio::is_radio_id(&item.video_id) + && text_ok(&item.video_id, 256) + && text_ok(&item.title, 1_024) + && text_ok(&item.artists, 1_024) + && thumbnail_ok +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_tauri_command_has_a_method() { + let lib = include_str!("../../../src-tauri/src/lib.rs"); + let list = &lib[lib.find("generate_handler![").unwrap()..]; + let list = &list[..list.find(']').unwrap()]; + let me = include_str!("methods.rs"); + for name in list + .split(',') + .map(|s| s.trim().trim_start_matches("commands::")) + .filter(|s| !s.is_empty() && !s.starts_with("generate_handler")) + { + assert!(me.contains(&format!("\"{name}\"")), "no socket method for command {name}"); + } + } + + #[test] + fn arg_decodes_camel_case_and_defaults_missing_to_null() { + let params = json!({ "videoId": "abc", "isUpload": true }); + assert_eq!(arg::(¶ms, "videoId").unwrap(), "abc"); + assert_eq!(arg::>(¶ms, "isUpload").unwrap(), Some(true)); + // A missing key decodes as an absent optional, and as an error for a required scalar. + assert_eq!(arg::>(¶ms, "from").unwrap(), None); + assert!(arg::(¶ms, "missing").is_err()); + } + + #[test] + fn proxy_setting_is_validated() { + assert!(normalize_proxy_setting("http://127.0.0.1:8080").is_ok()); + assert!(normalize_proxy_setting("file:///tmp/socket").is_err()); + assert!(normalize_proxy_setting("http://user:pw@proxy.example:8080").is_err()); + } + + #[test] + fn listen_together_url_must_be_a_websocket() { + assert!(normalize_lt_server_url("wss://example.com/room").is_ok()); + assert!(normalize_lt_server_url("https://example.com").is_err()); + } + + #[test] + fn playlist_artwork_collages_four_distinct_covers_else_first_or_none() { + // Nothing usable -> no artwork at all. + assert_eq!(auto_playlist_artwork(Vec::::new()), None); + assert_eq!(auto_playlist_artwork(vec!["", " "]), None); + // One to three distinct covers collapse to the first, in playlist order, trimmed & deduped. + assert_eq!( + auto_playlist_artwork(vec![" a ", "a", "", "b", "c"]), + Some("a".to_string()), + "fewer than four distinct covers use the first" + ); + // Exactly four distinct covers become the collage marker; order preserved, extras dropped. + assert_eq!( + auto_playlist_artwork(vec!["a", "a", "b", "c", "d", "e"]), + Some(r#"ryotunes-collage:["a","b","c","d"]"#.to_string()), + "four distinct covers form the 2x2 collage; duplicates and extras are ignored" + ); + } +} diff --git a/crates/ryotunesd/src/server.rs b/crates/ryotunesd/src/server.rs new file mode 100644 index 0000000..be9654a --- /dev/null +++ b/crates/ryotunesd/src/server.rs @@ -0,0 +1,287 @@ +//! Unix-socket server: one task per connection, requests dispatched concurrently, responses +//! and events multiplexed through a per-connection writer task. + +use std::os::unix::fs::PermissionsExt; +use std::os::unix::io::FromRawFd; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use ryotunes_protocol::{ErrorBody, Incoming, Outgoing, Request, Response}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::mpsc; + +use crate::lifecycle::Lifecycle; +use crate::sink::SocketSink; + +#[async_trait::async_trait] +pub trait Dispatch: Send + Sync + 'static { + async fn call( + &self, + method: &str, + params: Value, + conn: &Connection, + ) -> Result; +} + +/// What a method handler may do to the connection it arrived on. +pub struct Connection { + pub tx: mpsc::UnboundedSender, + pub sink: Arc, + lifecycle: Arc, + /// Set once so a connection counts as at most one subscriber for the idle-exit lifecycle, and so + /// the disconnect in [`handle`] only reports a departure for a connection that actually joined. + subscribed: AtomicBool, +} + +impl Connection { + /// `subscribe`: from now on every event reaches this connection, and the idle-exit deadline is + /// cancelled while it stays connected. Idempotent — a second `subscribe` on one connection is a + /// no-op rather than a double count. + pub fn subscribe(&self) { + if self.subscribed.swap(true, Ordering::AcqRel) { + return; + } + self.sink.subscribe(self.tx.clone()); + self.lifecycle.client_connected(); + } +} + +pub struct Server { + listener: UnixListener, + sink: Arc, + lifecycle: Arc, +} + +impl Server { + /// Adopt the systemd socket when socket-activated, otherwise bind the path with the directory at + /// 0700 and the socket at 0700 (a socket's base mode is 0777, so under umask 077 it lands + /// owner-only, 0700). + pub fn bind( + path: &Path, + sink: Arc, + lifecycle: Arc, + ) -> std::io::Result { + if let Some(listener) = systemd_listener()? { + // systemd already created the socket and directory with the unit's SocketMode/ + // DirectoryMode; adopt its fd instead of binding a second one. + return Ok(Server { listener, sink, lifecycle }); + } + let dir = path.parent().expect("socket path has a parent"); + std::fs::create_dir_all(dir)?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + let _ = std::fs::remove_file(path); + // Safe: umask is process-global and we restore it immediately; nothing else creates + // files during this window (the daemon is single-threaded until `run`). + let old = unsafe { libc::umask(0o077) }; + let listener = UnixListener::bind(path); + unsafe { libc::umask(old) }; + Ok(Server { listener: listener?, sink, lifecycle }) + } + + pub async fn run(self, dispatch: Arc) { + loop { + match self.listener.accept().await { + Ok((stream, _)) => { + tokio::spawn(handle( + stream, + dispatch.clone(), + self.sink.clone(), + self.lifecycle.clone(), + )); + } + Err(e) => tracing::warn!(error = %e, "accept failed"), + } + } + } +} + +/// True when started by systemd socket activation: `LISTEN_FDS >= 1` and `LISTEN_PID` names this +/// process. Both [`Server::bind`] (to adopt the fd) and `main` (to leave the systemd-owned socket +/// file in place on exit) ask this. +pub fn socket_activated() -> bool { + let fds = std::env::var("LISTEN_FDS").ok().and_then(|v| v.parse::().ok()).unwrap_or(0); + let pid = std::env::var("LISTEN_PID").ok().and_then(|v| v.parse::().ok()).unwrap_or(0); + fds >= 1 && pid == std::process::id() +} + +/// The passed-in listener at `SD_LISTEN_FDS_START` (fd 3) when socket-activated, else `None`. +fn systemd_listener() -> std::io::Result> { + if !socket_activated() { + return Ok(None); + } + // Safe: fd 3 is the single socket systemd created and passed for us to own. + let std_listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) }; + std_listener.set_nonblocking(true)?; + Ok(Some(UnixListener::from_std(std_listener)?)) +} + +async fn handle( + stream: UnixStream, + dispatch: Arc, + sink: Arc, + lifecycle: Arc, +) { + let (rd, mut wr) = stream.into_split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let writer = tokio::spawn(async move { + while let Some(line) = rx.recv().await { + if wr.write_all(line.as_bytes()).await.is_err() { + break; + } + } + }); + let conn = Arc::new(Connection { + tx: tx.clone(), + sink, + lifecycle: lifecycle.clone(), + subscribed: AtomicBool::new(false), + }); + let mut lines = BufReader::new(rd).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req = match serde_json::from_str::(&line) { + Ok(Incoming::Request(r)) => r, + Err(e) => { + crate::diagnostics::record( + "warn", + "client", + "rpc:framing", + &format!("unparsable request line: {e}"), + ); + let _ = tx.send( + Outgoing::Response(Response::err(0, "bad_request", e.to_string())).to_line(), + ); + continue; + } + }; + let dispatch = dispatch.clone(); + let conn = conn.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let Request { id, method, params } = req; + let resp = match dispatch.call(&method, params, &conn).await { + Ok(v) => Response::ok(id, v), + Err(e) => { + // The one chokepoint every RPC error passes: record it (minus codes that + // describe an expected state rather than a failure) so the Diagnostics page + // sees what actually broke, with the method name attached. + if !matches!(e.code.as_str(), "spotify_signed_out" | "client_side") { + crate::diagnostics::record( + "error", + "daemon", + &format!("rpc:{method}"), + &format!("[{}] {}", e.code, e.message), + ); + } + Response { id, result: None, error: Some(e) } + } + }; + let _ = tx.send(Outgoing::Response(resp).to_line()); + }); + } + // The client is gone: if it had subscribed, its departure may re-arm the idle-exit deadline. + if conn.subscribed.load(Ordering::Acquire) { + lifecycle.client_gone(); + } + drop(tx); + // The sink still holds this connection's sender, so the writer would idle until the next + // event failed a write to the dead socket; abort it now so the receiver goes and every clone + // of the sender reports closed. That is what lets `subscriber_count` see the departure + // straight away (a `show` right after a client was killed must launch one, not emit into + // the void). + writer.abort(); + let _ = writer.await; +} + +#[cfg(test)] +mod tests { + use super::*; + use ryotunes_core::host::EventSink; + + struct Echo; + #[async_trait::async_trait] + impl Dispatch for Echo { + async fn call( + &self, + method: &str, + params: Value, + conn: &Connection, + ) -> Result { + match method { + "echo" => Ok(params), + "subscribe" => { + conn.subscribe(); + Ok(Value::Null) + } + _ => Err(ErrorBody { code: "unknown_method".into(), message: method.into() }), + } + } + } + + #[tokio::test] + async fn echo_subscribe_and_event_fan_out() { + let dir = tempfile_dir(); + let path = dir.join("ryotunesd.sock"); + let sink = Arc::new(SocketSink::default()); + let (quit_tx, _quit_rx) = mpsc::unbounded_channel(); + let lifecycle = Lifecycle::new(quit_tx); + let server = Server::bind(&path, sink.clone(), lifecycle).unwrap(); + tokio::spawn(server.run(Arc::new(Echo))); + + let stream = UnixStream::connect(&path).await.unwrap(); + let (rd, mut wr) = stream.into_split(); + let mut lines = BufReader::new(rd).lines(); + + wr.write_all(b"{\"id\":1,\"method\":\"echo\",\"params\":{\"a\":1}}\n").await.unwrap(); + assert_eq!(lines.next_line().await.unwrap().unwrap(), "{\"id\":1,\"result\":{\"a\":1}}"); + + wr.write_all(b"{\"id\":2,\"method\":\"nope\"}\n").await.unwrap(); + let l = lines.next_line().await.unwrap().unwrap(); + assert!(l.contains("\"error\"") && l.contains("unknown_method")); + + assert_eq!(sink.subscriber_count(), 0); + wr.write_all(b"{\"id\":3,\"method\":\"subscribe\"}\n").await.unwrap(); + assert_eq!(lines.next_line().await.unwrap().unwrap(), "{\"id\":3,\"result\":null}"); + assert_eq!(sink.subscriber_count(), 1); + + sink.emit("position", serde_json::json!({ "position": 2.5 })); + assert_eq!( + lines.next_line().await.unwrap().unwrap(), + "{\"event\":\"position\",\"data\":{\"position\":2.5}}" + ); + + drop(wr); + drop(lines); + // The reader loop ends when the client goes and aborts the writer, so the sink's sender + // reports closed and the count drops on the next read, without an emit in between. + let mut pruned = false; + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if sink.subscriber_count() == 0 { + pruned = true; + break; + } + } + assert!(pruned, "closed subscriber was never pruned"); + + // A socket's base mode is 0777, so under umask 077 the file lands at 0700 (owner-only). + let meta = std::fs::metadata(&path).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + } + + fn tempfile_dir() -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!( + "ryotunesd-test-{}-{}", + std::process::id(), + rand_suffix() + )); + p + } + fn rand_suffix() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64 + } +} diff --git a/crates/ryotunesd/src/sink.rs b/crates/ryotunesd/src/sink.rs new file mode 100644 index 0000000..4fa0a00 --- /dev/null +++ b/crates/ryotunesd/src/sink.rs @@ -0,0 +1,52 @@ +//! `EventSink` over the socket: every event goes to every connection that subscribed. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +use ryotunes_core::host::EventSink; +use ryotunes_protocol::{Event, Outgoing}; +use serde_json::Value; +use tokio::sync::mpsc::UnboundedSender; + +#[derive(Default)] +pub struct SocketSink { + subscribers: Mutex>>, + count: AtomicUsize, +} + +impl SocketSink { + pub fn subscribe(&self, tx: UnboundedSender) { + self.subscribers.lock().unwrap().push(tx); + self.count.fetch_add(1, Ordering::Relaxed); + } + + /// Live subscribers, after dropping the ones whose connection has gone. A client killed + /// outright leaves its sender open until the next emit would find it closed; `show` reads + /// this to decide between raising a client and launching one, so it must not count ghosts + /// (a stale count kept the daemon emitting `show` to nobody instead of spawning the client). + pub fn subscriber_count(&self) -> usize { + let mut subs = self.subscribers.lock().unwrap(); + self.prune(&mut subs); + self.count.load(Ordering::Relaxed) + } + + fn prune(&self, subs: &mut Vec>) { + let before = subs.len(); + subs.retain(|tx| !tx.is_closed()); + let dropped = before - subs.len(); + if dropped > 0 { + self.count.fetch_sub(dropped, Ordering::Relaxed); + } + } +} + +impl EventSink for SocketSink { + fn emit(&self, event: &'static str, payload: Value) { + let line = Outgoing::Event(Event { event: event.to_owned(), data: payload }).to_line(); + let mut subs = self.subscribers.lock().unwrap(); + self.prune(&mut subs); + for tx in subs.iter() { + let _ = tx.send(line.clone()); + } + } +} diff --git a/crates/ryotunesd/src/tray.rs b/crates/ryotunesd/src/tray.rs new file mode 100644 index 0000000..82c5144 --- /dev/null +++ b/crates/ryotunesd/src/tray.rs @@ -0,0 +1,132 @@ +//! System tray: app icon + menu (Show / Play-Pause / Next / Previous / Quit), a straight port of +//! the Linux backend in `src-tauri/src/tray.rs`. `ksni` speaks StatusNotifierItem directly, so a +//! left-click reaches [`RyotunesTray::activate`] (libappindicator never delivers one). Menu actions +//! route into the same [`AppState`] methods the OS media keys use, so the tray can never behave +//! differently from MPRIS. Show goes through [`show`], the daemon's single "come back" path. + +use std::sync::Arc; +use std::sync::OnceLock; + +use ksni::menu::{MenuItem, StandardItem}; +use ksni::{Handle, Tray, TrayMethods}; +use ryotunes_core::host::EventSink; +use ryotunes_core::state::AppState; +use serde_json::Value; +use tokio::sync::mpsc::UnboundedSender; + +use crate::sink::SocketSink; + +/// The `Handle` isn't `Clone` and lives for the process, so it sits in a `OnceLock` the same way +/// the Tauri backend keeps it out of managed state — that also lets [`set_playing`] update the +/// label without borrowing across an await. +static HANDLE: OnceLock> = OnceLock::new(); + +struct RyotunesTray { + state: Arc, + quit: UnboundedSender<()>, + sink: Arc, + playing: bool, +} + +impl RyotunesTray { + /// Menu ids are the contract with [`menu`](RyotunesTray::menu); Show/Quit are handled inline and + /// the transport actions spawn onto the runtime like the Tauri backend's `handle_menu`. + fn handle_menu(&self, id: &str) { + match id { + "show" => show(&self.sink), + "quit" => { + let _ = self.quit.send(()); + } + other => { + let state = self.state.clone(); + let id = other.to_string(); + tokio::spawn(async move { + match id.as_str() { + "play_pause" => state.resume_or_toggle().await, + "next" => state.next_in_queue().await, + "prev" => state.prev_in_queue().await, + _ => {} + } + }); + } + } + } +} + +impl Tray for RyotunesTray { + fn id(&self) -> String { + "ryotunes".into() + } + + fn title(&self) -> String { + "Ryotunes".into() + } + + /// The packaged `ryotunes` hicolor icon (installed by the PKGBUILD); the StatusNotifierItem + /// resolves it from the theme, so no in-process pixmap is needed without an `AppHandle`. + fn icon_name(&self) -> String { + "ryotunes".into() + } + + /// The entire reason this backend exists: the desktop dispatches a left-click here. + fn activate(&mut self, _x: i32, _y: i32) { + show(&self.sink); + } + + fn menu(&self) -> Vec> { + let item = |label: &str, id: &'static str| { + MenuItem::from(StandardItem { + label: label.into(), + activate: Box::new(move |t: &mut Self| t.handle_menu(id)), + ..Default::default() + }) + }; + vec![ + item("Show Ryotunes", "show"), + MenuItem::Separator, + item(if self.playing { "Pause" } else { "Play" }, "play_pause"), + item("Next", "next"), + item("Previous", "prev"), + MenuItem::Separator, + item("Quit", "quit"), + ] + } +} + +/// The daemon's "come back" path, shared by the tray (Show / left-click), the `show` socket +/// method (a second `ryotunesd` launch) and the Tauri binary deferring to a live daemon. A +/// subscribed client is told to raise its window; with no client listening the native client +/// (`ryotunes-qml`) is spawned detached. It is the only client that speaks to this daemon: the +/// Tauri app carries its own player, so launching it here would put two engines on the same audio +/// device (it was once selected by `RYOTUNES_CLIENT`; that produced exactly that bug). +pub fn show(sink: &Arc) { + if sink.subscriber_count() > 0 { + sink.emit("show", Value::Null); + return; + } + if let Err(e) = std::process::Command::new("ryotunes-qml").spawn() { + tracing::warn!(error = %e, "`show` could not launch ryotunes-qml"); + } +} + +/// Register the tray. Registration with the StatusNotifierWatcher is async and can fail (no +/// watcher on the bus); that costs the tray, not the daemon, so it is logged, not propagated. +pub fn spawn(state: Arc, quit: UnboundedSender<()>, sink: Arc) { + let tray = RyotunesTray { state, quit, sink, playing: false }; + tokio::spawn(async move { + match tray.spawn().await { + Ok(handle) => { + let _ = HANDLE.set(handle); + } + Err(e) => tracing::error!("tray: StatusNotifierItem registration failed: {e}"), + } + }); +} + +/// Flip the Play/Pause label to match playback, driven from the event pump's `Playing` arm. +pub fn set_playing(playing: bool) { + let Some(handle) = HANDLE.get() else { return }; + tokio::spawn(async move { + handle.update(|t| t.playing = playing).await; + }); +} diff --git a/crates/soundcloud/Cargo.toml b/crates/soundcloud/Cargo.toml new file mode 100644 index 0000000..9d0219c --- /dev/null +++ b/crates/soundcloud/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ryotunes-soundcloud" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes SoundCloud provider: api-v2 client (guest scrape + OAuth bearer, HLS streams, waveforms)." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +base64 = "0.22" +thiserror = { workspace = true } +tracing = { workspace = true } +parking_lot = "0.12" +tokio = { workspace = true, features = ["fs", "sync"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "json"] } +regex = "1" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/soundcloud/src/auth.rs b/crates/soundcloud/src/auth.rs new file mode 100644 index 0000000..42622cb --- /dev/null +++ b/crates/soundcloud/src/auth.rs @@ -0,0 +1,181 @@ +//! Signed-in support: the OAuth bearer the host captures from the web player's cookie jar, its +//! JWT lifetime, and SoundCloud's rotating refresh-token exchange. +//! +//! SoundCloud's own web app keeps its access token in a JS-readable `oauth_token` cookie and a +//! single-use `oauth_refresh_token` (httpOnly) cookie, and refreshes through +//! `POST https://soundcloud.com/n/api/refresh-token` with `credentials: include`. The exchange +//! ROTATES the refresh token: the old one is dead the moment the new one is issued. A client that +//! spends one token twice (two concurrent refreshes) therefore logs itself out — so every refresh +//! runs under one async lock and re-checks whether a sibling already replaced the token before +//! spending another. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +/// The refresh endpoint the site itself uses (cookie-authenticated, JSON body). +pub const REFRESH_URL: &str = "https://soundcloud.com/n/api/refresh-token"; + +/// Re-refresh this many seconds before the access token actually dies, so a request that starts +/// near the boundary never carries an expired bearer. +const EXPIRY_MARGIN_SECS: i64 = 60; +/// Fallback lifetime for a token whose JWT we cannot parse (the site's documented ~1 h). +const UNKNOWN_LIFETIME_SECS: i64 = 3600; +/// The web app's own client id, used as the fallback when a JWT carries no readable claim. +const FALLBACK_CLIENT_ID: &str = "3uJIGBRwdofKn6QKzONvDxUM1Vs4bTv9"; + +/// Everything needed to act as the signed-in user and to keep acting after the access token +/// expires. Serializable: the host persists this blob (and rewrites it on every rotation) under a +/// settings key the renderer can never read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SoundcloudAuth { + pub access_token: String, + /// The single-use rotation token. `None` only if capture somehow saw no refresh cookie — + /// then the auth dies with the access token and the user re-signs. + pub refresh_token: Option, + /// The `client_id` claim inside the JWT: the refresh exchange must echo it back. + pub client_id: String, + /// Unix seconds when the access token dies. + pub expires_at: i64, + /// The `connect_session` cookie the refresh endpoint authenticates with. + pub connect_session: Option, + /// The account's display name, filled in by the host after verifying the token against + /// `/me`, so the status line survives restarts without another round trip. + #[serde(default)] + pub username: Option, +} + +impl SoundcloudAuth { + /// Build auth from a captured bearer: the JWT carries its own expiry and client id, so a + /// capture needs no extra round trip. Returns `None` only for an empty token. + pub fn from_access_token( + access_token: String, + refresh_token: Option, + connect_session: Option, + ) -> Option { + if access_token.trim().is_empty() { + return None; + } + let claims = decode_jwt_claims(&access_token); + let now = now_secs(); + let expires_at = claims.as_ref().and_then(|c| c.exp).unwrap_or(now + UNKNOWN_LIFETIME_SECS); + let client_id = claims + .as_ref() + .and_then(|c| c.client_id.clone()) + .unwrap_or_else(|| FALLBACK_CLIENT_ID.to_string()); + Some(SoundcloudAuth { + access_token, + refresh_token, + client_id, + expires_at, + connect_session, + username: None, + }) + } + + /// Whether the bearer is dead or about to be. + pub fn is_expired(&self, now: i64) -> bool { + self.expires_at <= now + EXPIRY_MARGIN_SECS + } + + /// The `Cookie` header the refresh endpoint needs (it authenticates the browser session). + pub fn refresh_cookie(&self) -> Option { + let session = self.connect_session.as_deref()?; + let mut cookie = format!("connect_session={session}"); + if let Some(rt) = &self.refresh_token { + cookie.push_str(&format!("; oauth_refresh_token={rt}")); + } + Some(cookie) + } +} + +/// The two JWT claims we care about. +#[derive(Deserialize)] +struct JwtClaims { + exp: Option, + #[serde(alias = "client_id", alias = "azp")] + client_id: Option, +} + +fn decode_jwt_claims(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = base64::prelude::BASE64_URL_SAFE_NO_PAD + .decode(payload.trim_end_matches('=')) + .or_else(|_| base64::prelude::BASE64_URL_SAFE.decode(payload)) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn now_secs() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +/// The refresh exchange's response. +#[derive(Deserialize)] +pub struct RefreshResponse { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A JWT-shaped token with a chosen exp/client_id payload, base64url-encoded like the site's. + fn fake_jwt(payload: serde_json::Value) -> String { + let enc = |bytes: &[u8]| { + base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(bytes).trim_end_matches('=').to_string() + }; + format!("{}.{}.sig", enc(b"{\"alg\":\"none\"}"), &enc(payload.to_string().as_bytes())) + } + + #[test] + fn reads_expiry_and_client_id_from_the_jwt() { + let auth = SoundcloudAuth::from_access_token( + fake_jwt(serde_json::json!({"exp": 5_000_000_i64, "client_id": "abc123"})), + None, + None, + ) + .unwrap(); + assert_eq!(auth.expires_at, 5_000_000); + assert_eq!(auth.client_id, "abc123"); + assert!(auth.is_expired(5_000_000 - 30)); // inside the margin + assert!(!auth.is_expired(5_000_000 - 3600)); + } + + #[test] + fn an_unparseable_token_still_authenticates_with_a_fallback_lifetime() { + let now = now_secs(); + let auth = SoundcloudAuth::from_access_token("not-a-jwt".into(), None, Some("sess".into())) + .unwrap(); + assert!(auth.expires_at > now); + assert_eq!(auth.client_id, FALLBACK_CLIENT_ID); + assert_eq!( + auth.refresh_cookie().as_deref(), + Some("connect_session=sess"), + "the refresh call authenticates with the session cookie" + ); + } + + #[test] + fn an_empty_token_is_no_auth() { + assert!(SoundcloudAuth::from_access_token(" ".into(), None, None).is_none()); + } + + #[test] + fn a_blob_without_a_username_round_trips() { + let auth = + SoundcloudAuth::from_access_token("not-a-jwt".into(), Some("rt".into()), None).unwrap(); + let json = serde_json::to_string(&auth).unwrap(); + let back: SoundcloudAuth = serde_json::from_str(&json).unwrap(); + assert_eq!(back, auth); + // An older persisted blob (no username key at all) still parses. + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + let mut obj = v.as_object().unwrap().clone(); + obj.remove("username"); + let old: SoundcloudAuth = serde_json::from_value(serde_json::Value::Object(obj)).unwrap(); + assert_eq!(old.username, None); + } +} diff --git a/crates/soundcloud/src/client.rs b/crates/soundcloud/src/client.rs new file mode 100644 index 0000000..b03a53d --- /dev/null +++ b/crates/soundcloud/src/client.rs @@ -0,0 +1,639 @@ +//! The `SoundCloud` client: `client_id` discovery + cache + one-retry for guests, an OAuth +//! bearer (with rotating refresh) once the host installs a sign-in, and every endpoint the +//! daemon bridge needs. Pure transport — no UI, no mpv. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::de::DeserializeOwned; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::auth::{SoundcloudAuth, REFRESH_URL}; +use crate::models::*; +use crate::{asset_script_srcs, downsample_waveform, scrape_client_id_from_js}; + +const API: &str = "https://api-v2.soundcloud.com"; +const HOME: &str = "https://soundcloud.com/"; +/// A current desktop-Chrome UA; SoundCloud serves the client_id bundles only to browser-shaped UAs. +const UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/124.0.0.0 Safari/537.36"; +/// The page limit used across listing endpoints. +const LIMIT: &str = "20"; +/// Hydration batch size for `/tracks?ids=…`. +const HYDRATE_BATCH: usize = 50; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("could not discover a SoundCloud client_id")] + ClientId, + #[error("SoundCloud returned HTTP {status} for {url}")] + Api { status: u16, url: String }, + #[error("track {0} has no playable HLS transcoding")] + NoStream(u64), + #[error("track {0} has no waveform")] + NoWaveform(u64), +} + +pub type Result = std::result::Result; + +/// A SoundCloud client: guest-capable, and signed-in once an OAuth bearer is installed. Cheap +/// to clone the way callers expect an `Arc` wrapper (holds a pooled `reqwest::Client` and +/// shared, refreshable `client_id` / auth). +pub struct SoundCloud { + http: reqwest::Client, + cache_dir: PathBuf, + client_id: RwLock>, + /// The current sign-in, installed by the host after capture or a refresh. When present its + /// bearer rides every api-v2 request as `Authorization: OAuth `, which is what + /// unlocks `/me` and the personal endpoints. A sync (parking_lot) lock: the guards are + /// only ever held long enough to clone, never across an `.await` — so the plain accessors + /// below are safe to call from async context (a tokio `blocking_read` there panics). + auth: parking_lot::RwLock>, + /// Serialises refreshes: SoundCloud's refresh token is single-use and rotates on every + /// exchange, so two concurrent refreshes would spend one token twice and log the session + /// out. The loser re-reads what the winner installed instead of exchanging again. + refresh_lock: tokio::sync::Mutex<()>, + /// Called after a successful refresh so the host can persist the rotated pair (the crate + /// owns no storage for secrets). + on_rotate: parking_lot::RwLock>>, +} + +impl SoundCloud { + /// `cache_dir` is where the discovered `client_id` is persisted (`/client_id`); + /// the caller passes `/soundcloud`. + pub fn new(cache_dir: PathBuf) -> Self { + let http = reqwest::Client::builder() + .user_agent(UA) + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(60)) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .expect("reqwest client builds with rustls"); + SoundCloud { + http, + cache_dir, + client_id: RwLock::new(None), + auth: parking_lot::RwLock::new(None), + refresh_lock: tokio::sync::Mutex::new(()), + on_rotate: parking_lot::RwLock::new(None), + } + } + + fn cache_file(&self) -> PathBuf { + self.cache_dir.join("client_id") + } + + // --- client_id lifecycle ------------------------------------------------- + + /// The current `client_id`: in-memory, then disk cache, then a fresh scrape. + async fn client_id(&self) -> Result { + if let Some(id) = self.client_id.read().await.clone() { + return Ok(id); + } + if let Ok(disk) = tokio::fs::read_to_string(self.cache_file()).await { + let disk = disk.trim().to_string(); + if disk.len() == 32 { + *self.client_id.write().await = Some(disk.clone()); + return Ok(disk); + } + } + self.refresh_client_id().await + } + + /// Scrape a fresh `client_id`, replacing the cached value (memory + disk). Called on startup + /// discovery and once on a 401/403 (the public id rotates). + async fn refresh_client_id(&self) -> Result { + let id = self.scrape_client_id().await?; + *self.client_id.write().await = Some(id.clone()); + let _ = tokio::fs::create_dir_all(&self.cache_dir).await; + let _ = tokio::fs::write(self.cache_file(), &id).await; + tracing::debug!(client_id = %id, "discovered SoundCloud client_id"); + Ok(id) + } + + // --- sign-in ------------------------------------------------------------- + + /// Install (or clear, with `None`) the sign-in. Takes effect through every clone + /// immediately, the same way the shared `client_id` does. + pub fn set_auth(&self, auth: Option) { + *self.auth.write() = auth; + } + + /// A snapshot of the current sign-in, if any. + pub fn auth(&self) -> Option { + self.auth.read().clone() + } + + /// Whether a bearer is installed (the client presents itself as signed in). An expired + /// bearer still counts until proven dead: expiry is repaired by `ensure_fresh` on use. + pub fn signed_in(&self) -> bool { + self.auth.read().is_some() + } + + /// Register the persistence hook for rotated tokens (see `SoundcloudAuth`'s single-use + /// refresh rotation). + pub fn set_on_rotate(&self, hook: std::sync::Arc) { + *self.on_rotate.write() = Some(hook); + } + + /// The signed-in user (`/me`). Requires a token — without one SoundCloud answers 401 and + /// this returns `Err(Api{{401}})`, which is also how the host detects a dead token. + pub async fn me(&self) -> Result { + self.ensure_fresh().await?; + let url = format!("{API}/me"); + let w: WireUser = self.get_json(&url, &[]).await?; + Ok(map_user(w)) + } + + /// Refresh the bearer if it is (nearly) expired, exactly once per rotation. A refresh + /// failure with a still-valid token is ignored (the request may well succeed); with a dead + /// token it surfaces so the caller can report the sign-in as broken. + async fn ensure_fresh(&self) -> Result<()> { + let Some(current) = self.auth.read().clone() else { return Ok(()) }; + let now = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + if !current.is_expired(now) { + return Ok(()); + } + if current.refresh_token.is_none() || current.connect_session.is_none() { + // Nothing to rotate with: the bearer is what it is. Personal calls will 401 and the + // host will ask the user to sign in again. + return Ok(()); + } + let _guard = self.refresh_lock.lock().await; + // Re-check under the lock: a sibling refresh may have landed while this call queued. + let Some(auth) = self.auth.read().clone() else { return Ok(()) }; + if !auth.is_expired(now) { + return Ok(()); + } + let rotated = self.refresh_token(&auth).await?; + *self.auth.write() = Some(rotated.clone()); + if let Some(hook) = self.on_rotate.read().clone() { + hook(rotated); + } + Ok(()) + } + + /// The site's own refresh exchange. The response rotates BOTH tokens; the old refresh + /// token is dead server-side the moment this call succeeds. + async fn refresh_token(&self, auth: &SoundcloudAuth) -> Result { + let cookie = auth + .refresh_cookie() + .ok_or_else(|| Error::Api { status: 401, url: REFRESH_URL.to_string() })?; + let body = serde_json::json!({ + "client_id": auth.client_id, + "grant_type": "refresh_token", + "refresh_token": auth.refresh_token, + }); + let resp = self + .http + .post(REFRESH_URL) + .header(reqwest::header::COOKIE, cookie) + .header(reqwest::header::REFERER, HOME) + .header(reqwest::header::ORIGIN, "https://soundcloud.com") + .json(&body) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + return Err(Error::Api { status: status.as_u16(), url: REFRESH_URL.to_string() }); + } + let wire: crate::auth::RefreshResponse = resp.json().await?; + let now = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let mut rotated = SoundcloudAuth::from_access_token( + wire.access_token, + wire.refresh_token.or_else(|| auth.refresh_token.clone()), + auth.connect_session.clone(), + ) + .ok_or(Error::ClientId)?; + // The JWT's own exp wins; expires_in is the site's promise and the earlier of the two + // is the safe choice. + if let Some(secs) = wire.expires_in { + rotated.expires_at = rotated.expires_at.min(now + secs); + } + tracing::info!("soundcloud: refreshed the access token"); + Ok(rotated) + } + + /// The signed-in user's own playlists (`/users/{me}/playlists`). + pub async fn my_playlists(&self) -> Result> { + let me = self.me().await?; + let url = format!("{API}/users/{}/playlists", me.id); + let w: WireCollection = + self.get_json(&url, &[("limit", "50".into())]).await?; + Ok(w.collection.iter().map(map_playlist).collect()) + } + + /// The tracks the signed-in user liked (`/users/{me}/likes` — the same envelope + /// `user_likes` reads, reached through the token's identity). + pub async fn my_likes(&self) -> Result> { + let me = self.me().await?; + self.user_likes(me.id).await + } + + /// The users the signed-in account follows (`/users/{me}/followings`), for the + /// Following shelf. + pub async fn my_followings(&self) -> Result> { + let me = self.me().await?; + let url = format!("{API}/users/{}/followings", me.id); + let w: WireCollection = self.get_json(&url, &[("limit", "50".into())]).await?; + Ok(w.collection.into_iter().map(map_user).collect()) + } + + /// Fetch soundcloud.com, collect its asset bundles, and scan them (last first) for a client_id. + async fn scrape_client_id(&self) -> Result { + let html = self.http.get(HOME).send().await?.text().await?; + let mut srcs = asset_script_srcs(&html); + srcs.reverse(); + for src in srcs { + let js = match self.http.get(&src).send().await { + Ok(r) => r.text().await.unwrap_or_default(), + Err(_) => continue, + }; + if let Some(id) = scrape_client_id_from_js(&js) { + return Ok(id); + } + } + Err(Error::ClientId) + } + + // --- transport ----------------------------------------------------------- + + /// GET `url` with `extra` query params plus the current `client_id` (and the bearer token + /// when signed in); on 401/403 re-scrape the id once and retry. Returns the raw + /// (decompressed) body bytes. + async fn get_bytes(&self, url: &str, extra: &[(&str, String)]) -> Result> { + let personal = url.starts_with(API) && is_personal_path(url); + if personal { + // A near-expiry bearer is repaired before the call, not after the 401: the + // rotating refresh must be spent exactly once. + self.ensure_fresh().await?; + } + let id = self.client_id().await?; + let resp = self.send(url, extra, &id).await?; + let status = resp.status(); + if status.is_success() { + return Ok(resp.bytes().await?.to_vec()); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + // A signed-in 401 on a personal endpoint is a dead token, not a rotated public + // id; re-scraping cannot fix it, so surface the 401 (the host treats that as + // "token needs re-capture"). + if personal && self.signed_in() { + return Err(Error::Api { status: status.as_u16(), url: url.to_string() }); + } + let id = self.refresh_client_id().await?; + let resp = self.send(url, extra, &id).await?; + let status = resp.status(); + if status.is_success() { + return Ok(resp.bytes().await?.to_vec()); + } + return Err(Error::Api { status: status.as_u16(), url: url.to_string() }); + } + Err(Error::Api { status: status.as_u16(), url: url.to_string() }) + } + + async fn send( + &self, + url: &str, + extra: &[(&str, String)], + client_id: &str, + ) -> Result { + let mut req = self.http.get(url).query(extra).query(&[("client_id", client_id)]); + if let Some(token) = self.auth.read().as_ref().map(|a| a.access_token.clone()) { + // SoundCloud's web API takes its bearer scheme as `OAuth `, not `Bearer`. + req = req.header("Authorization", format!("OAuth {token}")); + } + Ok(req.send().await?) + } + + /// GET + deserialize JSON, with the client_id retry. + async fn get_json( + &self, + url: &str, + extra: &[(&str, String)], + ) -> Result { + let bytes = self.get_bytes(url, extra).await?; + Ok(serde_json::from_slice(&bytes)?) + } + + // --- search -------------------------------------------------------------- + + /// Mixed search (`/search`): tracks, users, and playlists (albums flagged via `is_album`). + pub async fn search(&self, q: &str) -> Result { + let url = format!("{API}/search"); + let w: WireCollection = + self.get_json(&url, &[("q", q.into()), ("limit", LIMIT.into())]).await?; + let mut out = SearchResults::default(); + for item in w.collection { + let kind = item.get("kind").and_then(|k| k.as_str()).map(str::to_owned); + match kind.as_deref() { + Some("track") => { + if let Ok(t) = serde_json::from_value::(item) { + out.tracks.push(map_track(t)); + } + } + Some("user") => { + if let Ok(u) = serde_json::from_value::(item) { + out.users.push(map_user(u)); + } + } + Some("playlist") => { + if let Ok(p) = serde_json::from_value::(item) { + out.playlists.push(map_playlist(&p)); + } + } + _ => {} + } + } + Ok(out) + } + + /// Paged track search (`/search/tracks`). + pub async fn search_tracks(&self, q: &str, offset: u32) -> Result> { + let url = format!("{API}/search/tracks"); + let w: WireCollection = self + .get_json( + &url, + &[("q", q.into()), ("limit", LIMIT.into()), ("offset", offset.to_string())], + ) + .await?; + Ok(page(w, offset, map_track)) + } + + /// User search (`/search/users`). + pub async fn search_users(&self, q: &str) -> Result> { + let url = format!("{API}/search/users"); + let w: WireCollection = + self.get_json(&url, &[("q", q.into()), ("limit", LIMIT.into())]).await?; + Ok(w.collection.into_iter().map(map_user).collect()) + } + + /// Playlist search (`/search/playlists`). + pub async fn search_playlists(&self, q: &str) -> Result> { + let url = format!("{API}/search/playlists"); + let w: WireCollection = + self.get_json(&url, &[("q", q.into()), ("limit", LIMIT.into())]).await?; + Ok(w.collection.iter().map(map_playlist).collect()) + } + + // --- tracks -------------------------------------------------------------- + + /// A single track (`/tracks/{id}`). + pub async fn track(&self, id: u64) -> Result { + let url = format!("{API}/tracks/{id}"); + let w: WireTrack = self.get_json(&url, &[]).await?; + Ok(map_track(w)) + } + + /// Hydrate many tracks (`/tracks?ids=…`), 50 per call, preserving the caller's order. + pub async fn tracks(&self, ids: &[u64]) -> Result> { + let mut by_id: HashMap = HashMap::with_capacity(ids.len()); + for chunk in ids.chunks(HYDRATE_BATCH) { + let joined = chunk.iter().map(u64::to_string).collect::>().join(","); + let url = format!("{API}/tracks"); + let batch: Vec = self.get_json(&url, &[("ids", joined)]).await?; + for w in batch { + by_id.insert(w.id, map_track(w)); + } + } + Ok(ids.iter().filter_map(|id| by_id.remove(id)).collect()) + } + + /// Resolve a playable HLS m3u8 URL for `track`, preferring aac (`audio/mp4`) then mp3 + /// (`audio/mpeg`). These URLs expire, so resolve at play time. + pub async fn stream_url(&self, track: &Track) -> Result { + let hls = |mime_frag: &'static str| { + track + .transcodings + .iter() + .find(move |t| t.protocol == "hls" && t.mime_type.contains(mime_frag)) + }; + let chosen = hls("mp4") + .or_else(|| hls("mpeg")) + .or_else(|| track.transcodings.iter().find(|t| t.protocol == "hls")) + .ok_or(Error::NoStream(track.id))?; + + let mut extra: Vec<(&str, String)> = Vec::new(); + if let Some(auth) = &track.authorization { + extra.push(("track_authorization", auth.clone())); + } + let resolved: StreamUrl = self.get_json(&chosen.url, &extra).await?; + Ok(resolved.url) + } + + /// Fetch `track`'s waveform and downsample it to 240 peaks scaled `0..=100`. The waveform JSON + /// is a public CDN payload (no client_id needed). + pub async fn waveform(&self, track: &Track) -> Result> { + let url = track.waveform_url.as_deref().ok_or(Error::NoWaveform(track.id))?; + let bytes = self.http.get(url).send().await?.bytes().await?; + let wave: WireWaveform = serde_json::from_slice(&bytes)?; + Ok(downsample_waveform(&wave.samples, wave.height, 240)) + } + + // --- users --------------------------------------------------------------- + + /// A user profile (`/users/{id}`). + pub async fn user(&self, id: u64) -> Result { + let url = format!("{API}/users/{id}"); + let w: WireUser = self.get_json(&url, &[]).await?; + Ok(map_user(w)) + } + + /// A user's tracks, paged (`/users/{id}/tracks`). + pub async fn user_tracks(&self, id: u64, offset: u32) -> Result> { + let url = format!("{API}/users/{id}/tracks"); + let w: WireCollection = + self.get_json(&url, &[("limit", LIMIT.into()), ("offset", offset.to_string())]).await?; + Ok(page(w, offset, map_track)) + } + + /// A user's albums (`/users/{id}/albums`). + pub async fn user_albums(&self, id: u64) -> Result> { + let url = format!("{API}/users/{id}/albums"); + let w: WireCollection = + self.get_json(&url, &[("limit", "50".into())]).await?; + Ok(w.collection.iter().map(map_playlist).collect()) + } + + /// A user's non-album playlists (`/users/{id}/playlists_without_albums`). + pub async fn user_playlists(&self, id: u64) -> Result> { + let url = format!("{API}/users/{id}/playlists_without_albums"); + let w: WireCollection = + self.get_json(&url, &[("limit", "50".into())]).await?; + Ok(w.collection.iter().map(map_playlist).collect()) + } + + /// A user's liked tracks (`/users/{id}/likes`), unwrapping the `{track}` envelope and dropping + /// liked playlists. + pub async fn user_likes(&self, id: u64) -> Result> { + let url = format!("{API}/users/{id}/likes"); + let w: WireCollection = + self.get_json(&url, &[("limit", "50".into())]).await?; + let mut out = Vec::new(); + for item in w.collection { + let track = match item.get("track") { + Some(t) => Some(t.clone()), + None if item.get("kind").and_then(|k| k.as_str()) == Some("track") => Some(item), + None => None, + }; + if let Some(t) = track { + if let Ok(w) = serde_json::from_value::(t) { + out.push(map_track(w)); + } + } + } + Ok(out) + } + + // --- playlists / related / charts --------------------------------------- + + /// A playlist with every track fully hydrated in order (`/playlists/{id}` hands back only the + /// first few tracks hydrated; the rest are `{id}` stubs, hydrated in 50-id batches). + pub async fn playlist(&self, id: u64) -> Result { + let url = format!("{API}/playlists/{id}"); + let w: WirePlaylist = self.get_json(&url, &[]).await?; + + let order: Vec = w.tracks.iter().map(|t| t.id).collect(); + let mut by_id: HashMap = HashMap::with_capacity(order.len()); + let mut missing: Vec = Vec::new(); + for wt in &w.tracks { + if wt.is_hydrated() { + by_id.insert(wt.id, map_track(wt.clone())); + } else { + missing.push(wt.id); + } + } + if !missing.is_empty() { + for t in self.tracks(&missing).await? { + by_id.insert(t.id, t); + } + } + let tracks = order.iter().filter_map(|id| by_id.remove(id)).collect(); + let description = crate::nonempty(w.description.clone()); + Ok(PlaylistDetail { playlist: map_playlist(&w), tracks, description }) + } + + /// Related / autoplay tracks (`/tracks/{id}/related`). + pub async fn related(&self, id: u64) -> Result> { + let url = format!("{API}/tracks/{id}/related"); + let w: WireCollection = self.get_json(&url, &[("limit", LIMIT.into())]).await?; + Ok(w.collection.into_iter().map(map_track).collect()) + } + + /// The guest Discover feed (`/mixed-selections`): titled shelves of playlists and system + /// playlists. This replaces the retired `/charts` endpoint for guest Home. + pub async fn discover(&self) -> Result> { + let url = format!("{API}/mixed-selections"); + let w: WireCollection = + self.get_json(&url, &[("limit", LIMIT.into())]).await?; + let mut out = Vec::with_capacity(w.collection.len()); + for sel in w.collection { + let slug = + sel.urn.strip_prefix("soundcloud:selections:").unwrap_or(&sel.urn).to_string(); + let mut items = Vec::new(); + for item in sel.items.collection { + match item.get("kind").and_then(|k| k.as_str()) { + Some("playlist") => { + if let Ok(p) = serde_json::from_value::(item) { + items.push(DiscoverItem::Playlist(map_playlist(&p))); + } + } + Some("system-playlist") => { + if let Ok(s) = serde_json::from_value::(item) { + items.push(DiscoverItem::System(map_system_playlist(&s))); + } + } + _ => {} + } + } + if !items.is_empty() { + out.push(Selection { slug, title: sel.title, items }); + } + } + Ok(out) + } + + /// Hydrate a system playlist (`/system-playlists/{urn}`) into a `PlaylistDetail`. `permalink` + /// is e.g. "trending-by-genre:trap"; the response carries id-stub tracks, hydrated via + /// `tracks()` in order. The synthetic playlist has id 0 and `set_type = "system"`. + pub async fn system_playlist(&self, permalink: &str) -> Result { + let urn = format!("soundcloud:system-playlists:{permalink}"); + let url = format!("{API}/system-playlists/{urn}"); + let w: WireSystemPlaylist = self.get_json(&url, &[]).await?; + + let ids: Vec = w.tracks.iter().map(|t| t.id).collect(); + let tracks = self.tracks(&ids).await?; + let duration_ms = tracks.iter().map(|t| t.duration_ms).sum(); + let system = map_system_playlist(&w); + let playlist = Playlist { + id: 0, + title: system.title.clone(), + user: UserRef::default(), + artwork: system.artwork.clone(), + is_album: false, + set_type: Some("system".to_string()), + track_count: system.track_count, + release_date: None, + duration_ms, + }; + Ok(PlaylistDetail { playlist, tracks, description: system.description.clone() }) + } +} + +/// Whether an api-v2 URL asks for the signed-in user's own data (`/me`, or a personal +/// collection like `/users/{id}/likes|followings|playlists` when the id is the caller's). +/// A 401 here with a token installed means the token died — re-scraping the public +/// client_id cannot help, so `get_bytes` surfaces the 401 instead of burning a retry. +fn is_personal_path(url: &str) -> bool { + let Some(rest) = url.strip_prefix(API) else { return false }; + let rest = rest.trim_start_matches('/'); + if rest == "me" || rest.starts_with("me/") { + return true; + } + rest.starts_with("users/") + && ["/likes", "/followings", "/playlists", "/track_likes", "/playlist_likes"] + .iter() + .any(|suffix| rest.ends_with(suffix)) +} + +/// Turn a `{collection, next_href}` envelope into a `Page`, deriving the next offset from the +/// presence of `next_href` (SoundCloud paginates these endpoints by numeric offset). +fn page(w: WireCollection, offset: u32, map: impl Fn(W) -> T) -> Page { + let items: Vec = w.collection.into_iter().map(map).collect(); + let next = w.next_href.map(|_| offset + items.len() as u32); + Page { items, next } +} + +/// The `/media/transcodings/…` resolution response. +#[derive(Deserialize)] +struct StreamUrl { + url: String, +} + +/// A waveform payload (`{width, height, samples}`). +#[derive(Deserialize, Default)] +struct WireWaveform { + #[serde(default)] + height: u32, + #[serde(default)] + samples: Vec, +} + +/// A `/mixed-selections` shelf: `{urn:"soundcloud:selections:", title, items:{collection}}`. +#[derive(Deserialize, Default)] +struct WireSelection { + #[serde(default)] + urn: String, + #[serde(default)] + title: String, + #[serde(default)] + items: WireCollection, +} diff --git a/crates/soundcloud/src/lib.rs b/crates/soundcloud/src/lib.rs new file mode 100644 index 0000000..ca285af --- /dev/null +++ b/crates/soundcloud/src/lib.rs @@ -0,0 +1,211 @@ +//! Ryotunes SoundCloud provider. +//! +//! A client for SoundCloud's internal `api-v2`. Guest mode needs no account: it discovers a +//! public `client_id` by scraping soundcloud.com's asset bundles, caches it in memory and on +//! disk, and re-scrapes once on a 401/403 (the id rotates). Signed-in mode ([`SoundcloudAuth`]) +//! adds the web player's OAuth bearer — captured from a login window's cookie jar by the host — +//! which unlocks `/me`, the user's playlists/likes/followings, and their personal feed; the +//! access token's expiry is read from its JWT and refreshed through the site's own rotating +//! refresh-token exchange. Tracks stream as HLS m3u8 playlists that mpv plays directly; +//! waveforms come from SoundCloud's own peak arrays, downsampled for the seek bar. +//! +//! Boundary: this crate knows nothing about GPUI, Tauri, mpv, or UI state. It maps SoundCloud's +//! (frequently field-absent) JSON into tolerant models and hands them to the daemon bridge. + +mod auth; +mod client; +mod models; + +pub use auth::SoundcloudAuth; +pub use client::{Error, Result, SoundCloud}; +pub use models::{ + DiscoverItem, Page, Playlist, PlaylistDetail, SearchResults, Selection, SystemPlaylist, Track, + Transcoding, User, UserRef, +}; + +use std::sync::LazyLock; + +use regex::Regex; + +/// Asset-bundle ` + + + "#; + let srcs = asset_script_srcs(html); + assert_eq!( + srcs, + vec![ + "https://a-v2.sndcdn.com/assets/0-abc.js".to_string(), + "https://a-v2.sndcdn.com/assets/49-xyz.js".to_string(), + ] + ); + } + + #[test] + fn tag_list_respects_quoted_multiword_tags() { + assert_eq!( + parse_tag_list(r#"dance "hip hop" pop "drum and bass""#), + vec!["dance", "hip hop", "pop", "drum and bass"] + ); + assert!(parse_tag_list("").is_empty()); + } +} diff --git a/crates/soundcloud/src/models.rs b/crates/soundcloud/src/models.rs new file mode 100644 index 0000000..8b43d64 --- /dev/null +++ b/crates/soundcloud/src/models.rs @@ -0,0 +1,416 @@ +//! Public models (the crate contract) and the tolerant wire structs they map from. +//! +//! SoundCloud's `api-v2` omits fields liberally and returns `{"id": …}` stubs for unhydrated +//! playlist tracks, so every wire field carries `#[serde(default)]` and mapping fills the gaps. + +use serde::Deserialize; + +use crate::{nonempty, parse_tag_list, upscale_artwork}; + +// --------------------------------------------------------------------------- +// Public models (contract) +// --------------------------------------------------------------------------- + +/// A single track. `artwork` is the 500×500 variant; `transcodings` are the raw stream options +/// (`stream_url` resolves one to an m3u8 at play time, since those URLs expire). +#[derive(Clone, Debug)] +pub struct Track { + pub id: u64, + pub title: String, + pub user: UserRef, + pub duration_ms: u64, + pub artwork: Option, + pub genre: Option, + pub tags: Vec, + pub plays: Option, + pub likes: Option, + pub created_at: String, + pub permalink_url: String, + pub waveform_url: Option, + pub authorization: Option, + pub transcodings: Vec, + pub description: Option, +} + +/// A stream option from a track's `media.transcodings`. Selection prefers `protocol == "hls"` with +/// an aac (`audio/mp4`) mime, then mp3 (`audio/mpeg`). +#[derive(Clone, Debug)] +pub struct Transcoding { + pub url: String, + pub protocol: String, + pub mime_type: String, + pub preset: Option, + pub quality: Option, +} + +/// The lightweight user embedded in a track/playlist. +#[derive(Clone, Debug, Default)] +pub struct UserRef { + pub id: u64, + pub username: String, + pub permalink: String, + pub avatar: Option, +} + +/// A fully resolved user (artist) profile. +#[derive(Clone, Debug, Default)] +pub struct User { + pub id: u64, + pub username: String, + pub full_name: Option, + pub permalink: String, + pub avatar: Option, + pub banner: Option, + pub followers: u64, + pub track_count: u64, + pub city: Option, + pub country: Option, + pub description: Option, + pub verified: bool, +} + +/// A playlist or album (`is_album` distinguishes them; albums are just playlists on SoundCloud). +#[derive(Clone, Debug)] +pub struct Playlist { + pub id: u64, + pub title: String, + pub user: UserRef, + pub artwork: Option, + pub is_album: bool, + pub set_type: Option, + pub track_count: u64, + pub release_date: Option, + pub duration_ms: u64, +} + +/// A playlist with its tracks fully hydrated (in original order). +#[derive(Clone, Debug)] +pub struct PlaylistDetail { + pub playlist: Playlist, + pub tracks: Vec, + pub description: Option, +} + +/// A page of results. `next` is the offset to pass back for the following page, if any. +#[derive(Clone, Debug)] +pub struct Page { + pub items: Vec, + pub next: Option, +} + +/// Mixed search results (the `/search` endpoint), split by kind. Albums surface as `playlists` +/// with `is_album == true`. +#[derive(Clone, Debug, Default)] +pub struct SearchResults { + pub tracks: Vec, + pub users: Vec, + pub playlists: Vec, +} + +/// A "system playlist" from the Discover feed — an algorithmic set (e.g. `trending-by-genre:trap`) +/// keyed by a permalink/urn rather than a numeric id. +#[derive(Clone, Debug, Default)] +pub struct SystemPlaylist { + pub permalink: String, + pub urn: String, + pub title: String, + pub short_title: String, + pub description: Option, + pub artwork: Option, + pub track_count: u64, +} + +/// One tile in a Discover selection: either a normal (numeric-id) playlist or a system playlist. +#[derive(Clone, Debug)] +pub enum DiscoverItem { + Playlist(Playlist), + System(SystemPlaylist), +} + +/// A Discover shelf (`/mixed-selections`): a titled row of playlists/system-playlists. +#[derive(Clone, Debug)] +pub struct Selection { + pub slug: String, + pub title: String, + pub items: Vec, +} + +// --------------------------------------------------------------------------- +// Wire models (SoundCloud api-v2 JSON) +// --------------------------------------------------------------------------- + +/// A `{collection, next_href}` envelope, used by search/listing endpoints. +#[derive(Deserialize, Default)] +pub(crate) struct WireCollection { + #[serde(default = "Vec::new")] + pub collection: Vec, + #[serde(default)] + pub next_href: Option, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireUserRef { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub username: String, + #[serde(default)] + pub permalink: String, + #[serde(default)] + pub avatar_url: Option, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireUser { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub username: String, + #[serde(default)] + pub full_name: Option, + #[serde(default)] + pub permalink: String, + #[serde(default)] + pub avatar_url: Option, + #[serde(default)] + pub followers_count: u64, + #[serde(default)] + pub track_count: u64, + #[serde(default)] + pub city: Option, + #[serde(default)] + pub country: Option, + #[serde(default)] + pub country_code: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub verified: bool, + #[serde(default)] + pub visuals: Option, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireVisuals { + #[serde(default)] + pub visuals: Vec, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireVisual { + #[serde(default)] + pub visual_url: Option, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireFormat { + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub mime_type: String, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireTranscoding { + #[serde(default)] + pub url: String, + #[serde(default)] + pub preset: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub format: WireFormat, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireMedia { + #[serde(default)] + pub transcodings: Vec, +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireTrack { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub duration: u64, + #[serde(default)] + pub artwork_url: Option, + #[serde(default)] + pub genre: Option, + #[serde(default)] + pub tag_list: Option, + #[serde(default)] + pub playback_count: Option, + #[serde(default)] + pub likes_count: Option, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub permalink_url: String, + #[serde(default)] + pub waveform_url: Option, + #[serde(default)] + pub track_authorization: Option, + #[serde(default)] + pub media: WireMedia, + #[serde(default)] + pub description: Option, +} + +impl WireTrack { + /// A bare `{"id": …}` stub (unhydrated playlist row) carries no user. + pub(crate) fn is_hydrated(&self) -> bool { + self.user.is_some() + } +} + +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WirePlaylist { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub artwork_url: Option, + #[serde(default)] + pub is_album: bool, + #[serde(default)] + pub set_type: Option, + #[serde(default)] + pub track_count: u64, + #[serde(default)] + pub release_date: Option, + #[serde(default)] + pub duration: u64, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tracks: Vec, +} + +/// A system playlist as it arrives inside `/mixed-selections` items and from +/// `/system-playlists/{urn}`. Its `id` is a urn string (not numeric), so we key off `urn`/ +/// `permalink`; `tracks` are `{id}` stubs hydrated by the client. +#[derive(Deserialize, Clone, Default)] +pub(crate) struct WireSystemPlaylist { + #[serde(default)] + pub urn: String, + #[serde(default)] + pub permalink: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub short_title: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub calculated_artwork_url: Option, + #[serde(default)] + pub artwork_url: Option, + #[serde(default)] + pub track_count: Option, + #[serde(default)] + pub tracks: Vec, +} + +// --------------------------------------------------------------------------- +// Mapping (wire -> public) +// --------------------------------------------------------------------------- + +pub(crate) fn map_user_ref(w: WireUserRef) -> UserRef { + UserRef { + id: w.id, + username: w.username, + permalink: w.permalink, + avatar: nonempty(w.avatar_url).map(|u| upscale_artwork(&u)), + } +} + +pub(crate) fn map_user(w: WireUser) -> User { + User { + id: w.id, + username: w.username, + full_name: nonempty(w.full_name), + permalink: w.permalink, + avatar: nonempty(w.avatar_url).map(|u| upscale_artwork(&u)), + banner: w + .visuals + .and_then(|v| v.visuals.into_iter().next()) + .and_then(|v| nonempty(v.visual_url)), + followers: w.followers_count, + track_count: w.track_count, + city: nonempty(w.city), + country: nonempty(w.country).or_else(|| nonempty(w.country_code)), + description: nonempty(w.description), + verified: w.verified, + } +} + +pub(crate) fn map_transcoding(w: WireTranscoding) -> Transcoding { + Transcoding { + url: w.url, + protocol: w.format.protocol, + mime_type: w.format.mime_type, + preset: nonempty(w.preset), + quality: nonempty(w.quality), + } +} + +pub(crate) fn map_track(w: WireTrack) -> Track { + Track { + id: w.id, + title: w.title, + user: w.user.map(map_user_ref).unwrap_or_default(), + duration_ms: w.duration, + artwork: nonempty(w.artwork_url).map(|u| upscale_artwork(&u)), + genre: nonempty(w.genre), + tags: w.tag_list.as_deref().map(parse_tag_list).unwrap_or_default(), + plays: w.playback_count, + likes: w.likes_count, + created_at: w.created_at, + permalink_url: w.permalink_url, + waveform_url: nonempty(w.waveform_url), + authorization: nonempty(w.track_authorization), + transcodings: w.media.transcodings.into_iter().map(map_transcoding).collect(), + description: nonempty(w.description), + } +} + +pub(crate) fn map_playlist(w: &WirePlaylist) -> Playlist { + Playlist { + id: w.id, + title: w.title.clone(), + user: w.user.clone().map(map_user_ref).unwrap_or_default(), + artwork: nonempty(w.artwork_url.clone()).map(|u| upscale_artwork(&u)), + is_album: w.is_album, + set_type: nonempty(w.set_type.clone()), + track_count: w.track_count, + release_date: nonempty(w.release_date.clone()), + duration_ms: w.duration, + } +} + +pub(crate) fn map_system_playlist(w: &WireSystemPlaylist) -> SystemPlaylist { + let urn = if w.urn.is_empty() { + format!("soundcloud:system-playlists:{}", w.permalink) + } else { + w.urn.clone() + }; + SystemPlaylist { + permalink: w.permalink.clone(), + urn, + title: w.title.clone(), + short_title: w.short_title.clone(), + description: nonempty(w.description.clone()), + artwork: nonempty(w.calculated_artwork_url.clone().or_else(|| w.artwork_url.clone())) + .map(|u| upscale_artwork(&u)), + track_count: w.track_count.unwrap_or(w.tracks.len() as u64), + } +} diff --git a/crates/soundcloud/tests/live_smoke.rs b/crates/soundcloud/tests/live_smoke.rs new file mode 100644 index 0000000..8e941f7 --- /dev/null +++ b/crates/soundcloud/tests/live_smoke.rs @@ -0,0 +1,102 @@ +//! Live SoundCloud smoke test (network test policy). NOT in the default run — it hits the real +//! guest api-v2, discovers a client_id, and exercises the whole path end to end: +//! cargo test -p ryotunes-soundcloud -- --ignored --nocapture +//! +//! It searches "klingande messiah", then fetches the track, its stream URL, its waveform, the +//! artist, the artist's albums, the Discover feed, and a system playlist — one line of evidence each. + +use ryotunes_soundcloud::{DiscoverItem, SoundCloud}; + +/// Host portion of a URL, for terse evidence output. +fn host_of(url: &str) -> &str { + url.split("://").nth(1).and_then(|rest| rest.split('/').next()).unwrap_or(url) +} + +#[tokio::test] +#[ignore = "hits the live SoundCloud api-v2; run with --ignored"] +async fn live_smoke() { + let cache = std::env::temp_dir().join("ryotunes-soundcloud-smoke"); + let sc = SoundCloud::new(cache); + + // 1. Search. + let page = sc.search_tracks("klingande messiah", 0).await.expect("search_tracks"); + assert!(!page.items.is_empty(), "search returned no tracks"); + let hit = page.items[0].clone(); + eprintln!("track id: {} — \"{}\" by {}", hit.id, hit.title, hit.user.username); + + // 2. Fetch the track by id. + let track = sc.track(hit.id).await.expect("track"); + assert_eq!(track.id, hit.id, "track(id) returned a different track"); + assert!(!track.transcodings.is_empty(), "track has no transcodings"); + + // 3. Stream URL — an HLS m3u8 on a SoundCloud CDN. + let stream = sc.stream_url(&track).await.expect("stream_url"); + let host = host_of(&stream); + eprintln!("stream url host: {host}"); + assert!( + host.contains("sndcdn") || host.contains("soundcloud"), + "stream host is not a SoundCloud CDN: {stream}" + ); + assert!(stream.contains(".m3u8"), "stream is not an m3u8: {stream}"); + + // 4. Waveform — 240 buckets, 0..=100. + let wave = sc.waveform(&track).await.expect("waveform"); + eprintln!("waveform samples: {} (max {})", wave.len(), wave.iter().copied().max().unwrap_or(0)); + assert_eq!(wave.len(), 240, "waveform not downsampled to 240"); + assert!(wave.iter().all(|&s| s <= 100), "waveform sample out of 0..=100"); + + // 5. The artist. + let user = sc.user(track.user.id).await.expect("user"); + eprintln!( + "user: {} — {} followers, {} tracks", + user.username, user.followers, user.track_count + ); + assert_eq!(user.id, track.user.id); + + // 6. The artist's albums. + let albums = sc.user_albums(user.id).await.expect("user_albums"); + eprintln!("albums: {}", albums.len()); + match albums.first() { + Some(a) => eprintln!("first album: \"{}\" (is_album={})", a.title, a.is_album), + None => eprintln!("first album: "), + } + + // 7. The Discover feed, and a system playlist drilled into from it. + let selections = sc.discover().await.expect("discover"); + assert!(!selections.is_empty(), "discover came back empty"); + let total_items: usize = selections.iter().map(|s| s.items.len()).sum(); + eprintln!("discover shelves: {} ({} items)", selections.len(), total_items); + eprintln!( + "first shelf: \"{}\" (slug {}) with {} items", + selections[0].title, + selections[0].slug, + selections[0].items.len() + ); + + // Find the first system playlist anywhere in the feed and hydrate it. + let system = selections + .iter() + .flat_map(|s| &s.items) + .find_map(|i| match i { + DiscoverItem::System(sp) => Some(sp.clone()), + DiscoverItem::Playlist(_) => None, + }) + .expect("discover has at least one system playlist"); + eprintln!("system playlist: \"{}\" (permalink {})", system.title, system.permalink); + + let detail = sc.system_playlist(&system.permalink).await.expect("system_playlist"); + eprintln!( + "hydrated system playlist \"{}\": {} tracks, set_type {:?}, id {}", + detail.playlist.title, + detail.tracks.len(), + detail.playlist.set_type, + detail.playlist.id + ); + assert!(!detail.tracks.is_empty(), "system playlist hydrated no tracks"); + assert_eq!(detail.playlist.id, 0); + assert_eq!(detail.playlist.set_type.as_deref(), Some("system")); + eprintln!( + "first system track: \"{}\" by {}", + detail.tracks[0].title, detail.tracks[0].user.username + ); +} diff --git a/crates/spotify/Cargo.toml b/crates/spotify/Cargo.toml new file mode 100644 index 0000000..22c12e0 --- /dev/null +++ b/crates/spotify/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "ryotunes-spotify" +version.workspace = true +edition = "2024" +license.workspace = true +description = "Ryotunes Spotify provider: librespot metadata + pipe-to-mpv streaming. Lifted from nolight132/sonora." + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true, features = ["rc"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util"] } +bytes = "1" +http = "1" +log = "0.4" +uuid = { version = "1", features = ["v4"] } +protobuf = "3" +url = "2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +oauth2 = { version = "5", default-features = false, features = ["reqwest", "rustls-tls"] } + +librespot-core = { version = "0.8.0", default-features = false, features = ["rustls-tls-native-roots"] } +librespot-oauth = { version = "0.8.0", default-features = false, features = ["rustls-tls-native-roots"] } +librespot-playback = { version = "0.8.0", default-features = false, features = ["rustls-tls-native-roots"] } +librespot-protocol = "0.8.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# Resolver pin, not used directly: librespot-core's build script uses vergen-gitcl 1.0.8, which +# depends on vergen-lib 0.1.x. vergen 9.1.0 bumped vergen-lib to 9.x, so cargo pairing them breaks +# librespot-core's build.rs (two vergen-lib versions). Constrain the shared `vergen` build-dep to +# 9.0.6 (the version Sonora locks) so any re-resolution stays consistent. +[build-dependencies] +vergen = "=9.0.6" diff --git a/crates/spotify/README.md b/crates/spotify/README.md new file mode 100644 index 0000000..8ced4a6 --- /dev/null +++ b/crates/spotify/README.md @@ -0,0 +1,192 @@ +# ryotunes-spotify + +A Spotify provider for Ryotunes: OAuth sign-in, rich metadata, and audio streamed into Ryotunes' +single libmpv engine. + +## Origin & license + +Lifted from **[nolight132/sonora](https://github.com/nolight132/sonora)**, `crates/music/src/spotify`, +commit **695242e**. Sonora is **GPL-3.0-or-later**; so is Ryotunes and so is this crate. librespot +itself is MIT. + +Streaming uses **librespot 0.8** with nolight132's librespot-audio fix. The workspace pins the whole +librespot set to that fork via `[patch.crates-io]` in the root `Cargo.toml` +(`github.com/nolight132/librespot`, rev `e7eb953d5848fd97bacd00e6c0e33500765eaa63`). + +### Lifted (near-verbatim, only `crate::spotify::` → `crate::` path rewrites) + +`pb.rs`, `wire.rs`, `collection.rs`, `collection2.rs`, `albums.rs`, `artists.rs`, `playlists.rs`, +`profiles.rs`, `search.rs`, `radio.rs`, `pathfinder.rs` + `pathfinder/{album,artist,browse,hashes,plays,search}.rs`, +and the domain types in `models.rs`. These carry Sonora's tests (protobuf/pb-codec/wire/pathfinder/ +hash-registry parsing), which run offline. + +### Rewritten / adapted + +- **`models.rs`** — trimmed to the types the Spotify code produces. `Lyrics::plain` no longer + romanizes (Sonora pulled in kakasi/deunicode; the `romanized` fields stay in the shapes but are + never populated here). +- **`lyrics.rs`** — synced lines are normalized locally (sort + close open ends) instead of Sonora's + 970-line LRC pipeline. Spotify's color-lyrics payload is already clean, so the heavy pass (and its + kakasi dependency) bought nothing. +- **`auth.rs`** — Sonora opened a browser or printed the URL to stdout and blocked. A daemon can do + neither, so the PKCE authorization-code flow is driven here (via `oauth2`, which librespot-oauth + wraps): the authorization URL is handed to a caller-supplied sink, and the loopback redirect is + caught by a one-shot listener. The credential cache, the Premium gate, and error classification are + Sonora's. +- **`client.rs`** — Sonora's `impl MusicApi for LibrespotClient` became inherent async methods on + `Client`; `search` returns one `SearchResults` bundle; the client owns the streaming engine. +- **`provider.rs`** — Sonora's `impl MusicProvider` became `SpotifyProvider::{new, restore, sign_in, + sign_out}` returning a `Client`. +- **`stream.rs`** — *replaces* Sonora's `playback.rs` + `sink.rs` + `audio.rs` (a cpal+rodio sink). + See below. No cpal, no rodio. +- **`credentials.rs`** — a small root-directory holder instead of Sonora's `dirs`-based cache module. + +## Audio: pipe → FIFO → mpv + +Ryotunes plays everything through one libmpv instance (tempo/pitch/fx on its filter chain), so +Spotify audio must reach mpv, not a soundcard. One librespot `Player` per `Client` decodes and writes +**raw S16LE / 44100 Hz / stereo** through librespot-playback's built-in `pipe` backend into a +per-session FIFO at `$XDG_RUNTIME_DIR/ryotunes/spotify-.pcm`. + +The daemon points mpv at the FIFO with the rawaudio demuxer: + +```text +loadfile replace \ + demuxer=rawaudio,demuxer-rawaudio-format=s16le,\ + demuxer-rawaudio-rate=44100,demuxer-rawaudio-channels=2 +``` + +`StreamHandle` exposes `fifo_path()`, `seek(Duration)`, `play()`, `pause()`, and +`next_event() -> Option` (`Playing | Paused | Position | EndOfTrack | Unavailable`, +mapped from librespot's `PlayerEvent`). + +### Pause caveat (important for the daemon) + +librespot **closes the pipe's write end when it pauses** (`handle_pause` → `ensure_sink_stopped`), +which mpv reads as end-of-file. So the daemon's normal pause should **pause mpv**, not call +`StreamHandle::pause()`: with mpv stopped reading, the FIFO fills and librespot's write blocks +(backpressure), keeping the pipe open. Use `StreamHandle::pause()`/`play()` only when a real +teardown/reload is acceptable. Gapless is **off** for v1 (each track is a fresh `loadfile`). + +Endianness: librespot emits native-endian S16, i.e. little-endian on Ryotunes' Linux x86-64 target, +matching `s16le`. + +## Type mapping → `ryotunes_core::innertube` (`SongItem`) + +The provider returns Sonora's richer types (`Track`, `Album`, `Artist`, `Playlist`, `Lyrics`). The +daemon converts a `Track` into the `SongItem`-like shape the existing InnerTube commands already +return, so Spotify rides the same command surface: + +| `SongItem` field | from `ryotunes_spotify::Track` | +| --- | --- | +| `video_id` | `"spotify:track:" + track.id` (the id is the Spotify base62; `None` ⇒ unplayable, skip) | +| `title` | `track.name` | +| `artists` | `track.artists` (already joined; `track.artist_refs` has per-artist ids) | +| `artist_id` | first `track.artist_refs[].id` → `"spotify:artist:"` | +| `album` | `track.album` | +| `album_id` | `track.album_id` → `"spotify:album:"` | +| `duration` | `track.duration` (format as `m:ss`) | +| `thumbnail` | `track.cover` | +| `explicit` | `track.explicit` | + +Albums/artists/playlists map the same way (`id` → `spotify:album:`/`spotify:artist:`/ +`spotify:playlist:`). `SearchResults.artists` is derived from the track hits' `artist_refs` (Spotify's +Pathfinder search has no artist entity), so it lists the artists the result tracks credit. + +**Id direction:** this crate *returns* bare base62 ids (`Track.id`, `Album.id`, …) and the daemon +prefixes them to `spotify:` URIs on the wire (above). Going the other way, the client methods +(`album`, `artist`, `playlist`, `track`, `lyrics`, …) *take* bare base62 ids, so the daemon strips the +`spotify::` prefix from a stored URI before calling — e.g. `id.rsplit_once(':').map_or(id, |(_, t)| t)`. +`Client::stream` is the exception: it accepts either a bare id or a full `spotify:track:` URI, so a +queued item's id can be played back as-is. + +Lyrics: `Lyrics::Synced { lines }` carries per-line `start`/`end` and, when Spotify provides syllable +timing, `words: Vec`. + +## Public API + +```rust +let provider = SpotifyProvider::new(data_dir); // credentials under /spotify +// restore cached credentials, if any: +if let Some(client) = provider.restore().await? { /* ... */ } +// or sign in; `on_url` receives the URL to open in a browser: +let client = provider.sign_in(|url| send_url_to_client(url)).await?; +provider.sign_out(); + +let results = client.search("radiohead").await?; // SearchResults { tracks, albums, artists, playlists } +let detail = client.album("").await?; // AlbumDetail +let artist = client.artist("").await?; // Artist { info, popular (top_tracks), releases (albums) } +let pl = client.playlist("").await?; // PlaylistDetail +let liked = client.liked(0).await?; // page 0 of saved tracks +let home = client.home().await?; // HomeFeed +let lyrics = client.lyrics("").await?; // Option +let mut s = client.stream("")?; // StreamHandle +``` + +`crates/core::spotify::SpotifyState` wraps the provider + current client behind a `tokio::sync::RwLock` +(`restore` / `sign_in` / `sign_out` / `status` / `client`) for the daemon. + +## Daemon wiring (to add in `crates/ryotunesd/src/methods.rs`) + +Spotify is an **additional** provider alongside YouTube Music, never a replacement — the InnerTube +path is untouched. The daemon holds a `provider` selector (`"youtube" | "spotify"`, set by a top-bar +switch) and routes the shared search/browse/library commands by it; `SpotifyState` (in +`core::spotify`) supplies the Spotify side. `methods.rs` is owned by another worker right now, so this +crate wires **no** daemon methods. + +Two ways to expose Spotify, both fine — pick per the daemon's existing shape: + +- **Routed:** the existing `search`/`browse_album`/`liked`/… methods read `provider` and dispatch to + `SpotifyState::client()` vs the InnerTube path. +- **Namespaced:** dedicated `spotify_*` methods (below), with `provider` still gating which the UI + calls. + +Because both catalogues share one command surface and one queue, **every id the daemon emits for a +Spotify item MUST be a `spotify:` URI** (`spotify:track:…`, `spotify:album:…`, `spotify:artist:…`, +`spotify:playlist:…`), built from the bare base62 ids this crate returns (see the mapping table). +YouTube ids (`videoId`, `UC…`, `MPRE…`) never carry that prefix, so the two catalogues can never +collide, and `spotify_play` / `stream` can tell a Spotify track from a YouTube one by the prefix. +Suggested JSON-RPC method names and shapes: + +| method | params | result | +| --- | --- | --- | +| `spotify_status` | `{}` | `{ "signed_in": bool, "stored": bool }` | +| `spotify_sign_in` | `{}` | streams `{ "auth_url": string }` first (from the `on_url` sink), then `{ "signed_in": true }` on success | +| `spotify_sign_out` | `{}` | `{ "signed_in": false }` | +| `spotify_search` | `{ "query": string }` | `{ "tracks": SongItem[], "albums": Album[], "artists": Artist[], "playlists": Playlist[] }` | +| `spotify_album` | `{ "id": string }` | `{ "album": Album, "tracks": SongItem[] }` | +| `spotify_artist` | `{ "id": string }` | `{ "info": Artist, "popular": SongItem[], "releases": Album[] }` | +| `spotify_playlist` | `{ "id": string }` | `{ "playlist": Playlist, "tracks": SongItem[] }` | +| `spotify_liked` | `{ "page": u32 }` | `{ "tracks": SongItem[] }` | +| `spotify_play` | `{ "track_id": string }` | `{ "fifo": string }` — then `loadfile` that FIFO into mpv with the rawaudio options above | + +`spotify_sign_in` should push the `auth_url` to the client (e.g. an event/notification) from inside +the `on_url` callback, since `sign_in` only resolves after the browser round-trip completes. +`SongItem`/`Album`/`Artist`/`Playlist` above are the daemon's existing InnerTube shapes, filled via +the mapping table. + +## Testing + +```sh +cargo test -p ryotunes-spotify +``` + +All unit tests are offline (protobuf/pb-codec/wire/pathfinder-request/hash-registry parsing, lyric +normalization, event mapping). Nothing here contacts Spotify. + +### Manual verification (needs a Spotify **Premium** account) + +librespot streaming requires Premium and cannot be exercised in unit tests. To verify end to end: + +1. Build a tiny harness (or use the daemon once `spotify_*` methods are wired) that calls + `SpotifyProvider::new()` then `sign_in(|url| println!("{url}"))`. +2. Open the printed URL in a browser, log in, approve. The loopback listener on + `127.0.0.1:8989/login` catches the redirect; `sign_in` returns a `Client`. +3. `client.profile().await` → your display name. `client.search("...")`, `client.album(id)`, + `client.liked(0)` → non-empty, well-formed data. +4. `client.lyrics(track_id)` on a track with lyrics → `Some(Lyrics::Synced { .. })` with word timing. +5. `let mut h = client.stream(track_id)?;` then `mpv --demuxer=rawaudio + --demuxer-rawaudio-format=s16le --demuxer-rawaudio-rate=44100 --demuxer-rawaudio-channels=2 + "$(printf '%s' "$(h.fifo_path())")"` — audio plays. `h.seek(30s)` jumps; watch `h.next_event()` + emit `Playing`/`Position`/`EndOfTrack`. +6. A non-Premium account must fail `sign_in` with `SignInFailure(SignInProblem::Premium)`. diff --git a/crates/spotify/src/albums.rs b/crates/spotify/src/albums.rs new file mode 100644 index 0000000..ab91b2e --- /dev/null +++ b/crates/spotify/src/albums.rs @@ -0,0 +1,196 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context as _, Result}; +use librespot_core::Session; +use librespot_protocol::extension_kind::ExtensionKind; +use librespot_protocol::metadata::Album as AlbumMessage; +use librespot_protocol::metadata::album::Type as AlbumType; +use protobuf::Message as _; + +use crate::{Album, AlbumDetail, ReleaseType, Track}; +use crate::{collection, collection2, pathfinder, wire}; + +const ALBUM_PREFIX: &str = "spotify:album:"; +const TRACK_PREFIX: &str = "spotify:track:"; +const UNKNOWN: &str = "Unknown"; + +pub async fn saved_albums(session: &Session, limit: u32) -> Result> { + let items = + collection2::saved_items(session, collection2::COLLECTION, ALBUM_PREFIX, limit as usize) + .await?; + if items.is_empty() { + return Ok(Vec::new()); + } + + let uris: Vec = items.iter().map(|item| item.uri.clone()).collect(); + let mut known = metadata(session, &uris).await?; + Ok(items + .into_iter() + .filter_map(|item| { + let mut album = known.remove(&item.uri)?; + album.added_at = item.added_at; + Some(album) + }) + .collect()) +} + +pub async fn album(session: &Session, album_id: &str) -> Result { + match pathfinder::album(session, album_id).await { + Ok(album) => return Ok(album), + Err(error) => log::warn!("albums: cannot load Pathfinder album: {error:#}"), + } + + legacy_album(session, album_id).await +} + +async fn legacy_album(session: &Session, album_id: &str) -> Result { + let uri = format!("{ALBUM_PREFIX}{album_id}"); + let message = + collection::extended(session, std::slice::from_ref(&uri), ExtensionKind::ALBUM_V4) + .await + .context("cannot read album metadata")? + .into_iter() + .find_map(|entity| AlbumMessage::parse_from_bytes(&entity.extension_data.value).ok()) + .context("album metadata is missing")?; + let album = album_from(&uri, &message); + let uris: Vec<_> = + track_ids(&message).into_iter().map(|id| format!("{TRACK_PREFIX}{id}")).collect(); + let tracks = match uris.is_empty() { + true => Vec::new(), + false => { + let known = collection::metadata(session, &uris).await?; + uris.iter().filter_map(|uri| known.get(uri).cloned()).collect() + } + }; + Ok(AlbumDetail { album, tracks }) +} + +pub async fn album_tracks(session: &Session, album_id: &str) -> Result> { + Ok(album(session, album_id).await?.tracks) +} + +fn track_ids(album: &AlbumMessage) -> Vec { + album + .disc + .iter() + .flat_map(|disc| disc.track.iter()) + .filter_map(|track| { + let gid = track.gid.as_ref()?; + librespot_core::SpotifyId::from_raw(gid).ok()?.to_base62().ok() + }) + .collect() +} + +pub(crate) async fn metadata(session: &Session, uris: &[String]) -> Result> { + let entities = collection::extended(session, uris, ExtensionKind::ALBUM_V4) + .await + .context("cannot read album metadata")?; + + let mut albums = HashMap::new(); + for entity in entities { + let Ok(message) = AlbumMessage::parse_from_bytes(&entity.extension_data.value) else { + continue; + }; + let album = album_from(&entity.entity_uri, &message); + albums.insert(entity.entity_uri, album); + } + Ok(albums) +} + +pub(crate) async fn track_uris(session: &Session, uris: &[String]) -> Result> { + let entities = collection::extended(session, uris, ExtensionKind::ALBUM_V4) + .await + .context("cannot read album metadata")?; + + let mut seen = HashSet::new(); + let mut tracks = Vec::new(); + for entity in entities { + let Ok(message) = AlbumMessage::parse_from_bytes(&entity.extension_data.value) else { + continue; + }; + for id in track_ids(&message) { + if seen.insert(id.clone()) { + tracks.push(format!("{TRACK_PREFIX}{id}")); + } + } + } + Ok(tracks) +} + +fn album_from(uri: &str, album: &AlbumMessage) -> Album { + let (artists, artist_refs) = collection::artists_from(&album.artist); + + Album { + id: uri.strip_prefix(ALBUM_PREFIX).unwrap_or(uri).to_owned(), + name: non_empty(album.name.as_deref()).unwrap_or(UNKNOWN).to_owned(), + artists, + artist_refs, + cover: cover(album), + cover_large: cover_large(album), + release_type: release_type(album.type_()), + year: album.date.as_ref().map(|date| date.year()).unwrap_or(0), + track_count: track_ids(album).len() as u32, + release_date: album + .date + .as_ref() + .map(|date| { + let year = date.year(); + let month = date.month(); + let day = date.day(); + match (month > 0, day > 0) { + (true, true) => format!("{year:04}-{month:02}-{day:02}"), + (true, false) => format!("{year:04}-{month:02}"), + _ => format!("{year:04}"), + } + }) + .unwrap_or_default(), + label: album.label().to_owned(), + copyrights: album + .copyright + .iter() + .filter_map(|copyright| non_empty(copyright.text.as_deref()).map(str::to_owned)) + .collect(), + added_at: None, + } +} + +fn release_type(kind: AlbumType) -> ReleaseType { + match kind { + AlbumType::ALBUM => ReleaseType::Album, + AlbumType::SINGLE => ReleaseType::Single, + AlbumType::COMPILATION => ReleaseType::Compilation, + AlbumType::EP => ReleaseType::Ep, + AlbumType::AUDIOBOOK => ReleaseType::Audiobook, + AlbumType::PODCAST => ReleaseType::Podcast, + } +} + +fn cover(album: &AlbumMessage) -> Option { + let smallest = album + .cover_group + .as_ref()? + .image + .iter() + .filter(|image| image.has_file_id()) + .min_by_key(|image| image.width())?; + + wire::image_url(smallest.file_id()) +} + +fn cover_large(album: &AlbumMessage) -> Option { + const HEADER: i32 = 300; + + let images = album.cover_group.as_ref()?.image.iter(); + let usable: Vec<_> = images.filter(|image| image.has_file_id()).collect(); + let picked = usable + .iter() + .filter(|image| image.width() >= HEADER) + .min_by_key(|image| image.width()) + .or_else(|| usable.iter().max_by_key(|image| image.width()))?; + + wire::image_url(picked.file_id()) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} diff --git a/crates/spotify/src/artists.rs b/crates/spotify/src/artists.rs new file mode 100644 index 0000000..6144cc5 --- /dev/null +++ b/crates/spotify/src/artists.rs @@ -0,0 +1,462 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context as _, Result}; +use librespot_core::{Session, SpotifyUri}; +use librespot_protocol::extended_metadata::{BatchedEntityRequest, EntityRequest, ExtensionQuery}; +use librespot_protocol::extension_kind::ExtensionKind; +use librespot_protocol::metadata::image::Size as ImageSize; +use librespot_protocol::metadata::{Artist as ArtistMessage, Image}; +use protobuf::{EnumOrUnknown, Message as _}; + +use crate::collection2::SavedItem; +use crate::{Album, Artist, ArtistProfile, ReleaseType, SavedArtist, Track}; +use crate::{albums, collection, collection2, pathfinder, wire}; + +const ARTIST_PREFIX: &str = "spotify:artist:"; +const ALBUM_PREFIX: &str = "spotify:album:"; +const TRACK_PREFIX: &str = "spotify:track:"; +const LARGE_PORTRAIT: i32 = 300; +const UNKNOWN: &str = "Unknown"; +const TOP_TRACKS: usize = 30; +const MINED_RELEASES: usize = 40; + +pub async fn artist(session: &Session, artist_id: &str) -> Result { + match pathfinder::artist(session, artist_id).await { + Ok(overview) => return overview_artist(session, artist_id, overview).await, + Err(error) => log::warn!("artists: cannot load Pathfinder artist: {error:#}"), + } + + legacy_artist(session, artist_id).await +} + +pub async fn profile(session: &Session, artist_id: &str) -> Result { + Ok(profile_from(&metadata(session, artist_id).await?)) +} + +async fn overview_artist( + session: &Session, + artist_id: &str, + overview: pathfinder::Overview, +) -> Result { + let track_uris: Vec<_> = overview.tracks.iter().map(|(uri, _)| uri.clone()).collect(); + let tracks = async { + match track_uris.is_empty() { + true => Ok(HashMap::::new()), + false => collection::metadata(session, &track_uris).await, + } + }; + let (tracks, discography) = tokio::join!(tracks, discography(session, artist_id)); + let mut known_tracks = tracks?; + let ranked = overview + .tracks + .into_iter() + .filter_map(|(uri, playcount)| { + let mut track = known_tracks.remove(&uri)?; + track.playcount = playcount; + Some(track) + }) + .collect(); + let albums = match discography { + Ok(releases) if !releases.is_empty() => releases, + Ok(_) => overview.albums, + Err(error) => { + log::warn!("artists: cannot read the discography: {error:#}"); + overview.albums + } + }; + let top_tracks = deepened(session, ranked, &albums).await; + + Ok(Artist { + name: overview.name, + cover_large: overview.cover_large, + biography: overview + .biography + .map(|biography| plain_text(&biography)) + .filter(|biography| !biography.is_empty()), + monthly_listeners: overview.monthly_listeners, + top_tracks, + albums, + }) +} + +async fn legacy_artist(session: &Session, artist_id: &str) -> Result { + let message = metadata(session, artist_id).await?; + + let track_uris = top_track_uris(&message, &session.country()); + let tracks = async { + match track_uris.is_empty() { + true => Ok(HashMap::::new()), + false => collection::metadata(session, &track_uris).await, + } + }; + let (tracks, releases) = tokio::join!(tracks, releases(session, &message)); + let known_tracks = tracks?; + let ranked = track_uris.iter().filter_map(|uri| known_tracks.get(uri).cloned()).collect(); + let releases = releases?; + let top_tracks = deepened(session, ranked, &releases).await; + + Ok(artist_from(&message, top_tracks, releases)) +} + +async fn deepened(session: &Session, ranked: Vec, releases: &[Album]) -> Vec { + if ranked.len() >= TOP_TRACKS || releases.is_empty() { + return ranked; + } + + match popular(session, releases, &ranked).await { + Ok(rest) => ranked.into_iter().chain(rest).take(TOP_TRACKS).collect(), + Err(error) => { + log::warn!("artists: cannot rank the rest of the catalogue: {error:#}"); + ranked + } + } +} + +async fn popular(session: &Session, releases: &[Album], known: &[Track]) -> Result> { + let mined: Vec = releases + .iter() + .filter(|album| matches!(album.release_type, ReleaseType::Album | ReleaseType::Ep)) + .take(MINED_RELEASES) + .map(|album| format!("{ALBUM_PREFIX}{}", album.id)) + .collect(); + if mined.is_empty() { + return Ok(Vec::new()); + } + log::debug!("artists: ranking the tracks of {} of {} releases", mined.len(), releases.len()); + + let uris = albums::track_uris(session, &mined).await?; + if uris.is_empty() { + return Ok(Vec::new()); + } + + let known_tracks = collection::metadata(session, &uris).await?; + let mut rest: Vec = uris + .iter() + .filter_map(|uri| known_tracks.get(uri)) + .filter(|track| track.playable && track.id.is_some()) + .cloned() + .collect(); + rest.sort_by_key(|track| std::cmp::Reverse(track.popularity)); + + let mut seen: HashSet = known.iter().map(|track| track.name.to_lowercase()).collect(); + rest.retain(|track| seen.insert(track.name.to_lowercase())); + Ok(rest) +} + +async fn discography(session: &Session, artist_id: &str) -> Result> { + let message = metadata(session, artist_id).await?; + releases(session, &message).await +} + +async fn releases(session: &Session, artist: &ArtistMessage) -> Result> { + let uris = release_uris(artist); + if uris.is_empty() { + return Ok(Vec::new()); + } + + let known = albums::metadata(session, &uris).await?; + Ok(newest_first(uris.iter().filter_map(|uri| known.get(uri).cloned()))) +} + +fn newest_first(releases: impl Iterator) -> Vec { + let mut seen = HashSet::new(); + let mut releases: Vec<_> = releases + .filter(|album| seen.insert((album.name.to_lowercase(), album.release_type))) + .collect(); + + releases.sort_by(|left, right| right.release_date.cmp(&left.release_date)); + releases +} + +async fn metadata(session: &Session, artist_id: &str) -> Result { + let uri = SpotifyUri::from_uri(&format!("{ARTIST_PREFIX}{artist_id}")) + .context("invalid artist ID")?; + let body = session + .spclient() + .get_artist_metadata(&uri) + .await + .context("cannot read artist metadata")?; + ArtistMessage::parse_from_bytes(&body).context("cannot decode artist metadata protobuf") +} + +pub async fn saved_artists(session: &Session, limit: u32) -> Result> { + let items = followed(session, limit as usize).await?; + if items.is_empty() { + return Ok(Vec::new()); + } + + let ids: Vec = items + .iter() + .filter_map(|item| item.uri.strip_prefix(ARTIST_PREFIX).map(str::to_owned)) + .collect(); + let mut known = cards(session, &ids).await?; + + Ok(items + .into_iter() + .filter_map(|item| { + let mut artist = known.remove(item.uri.strip_prefix(ARTIST_PREFIX)?)?; + artist.added_at = item.added_at; + Some(artist) + }) + .collect()) +} + +async fn followed(session: &Session, limit: usize) -> Result> { + match collection2::saved_items(session, collection2::ARTISTS, ARTIST_PREFIX, limit).await { + Ok(items) if !items.is_empty() => return Ok(items), + Ok(_) => log::debug!("artists: the followed set is empty, reading the collection set"), + Err(error) => log::warn!("artists: cannot read the followed set: {error:#}"), + } + + collection2::saved_items(session, collection2::COLLECTION, ARTIST_PREFIX, limit).await +} + +pub async fn images(session: &Session, ids: &[String]) -> Result> { + Ok(cards(session, ids) + .await? + .into_iter() + .filter_map(|(id, artist)| artist.cover.map(|url| (id, url))) + .collect()) +} + +async fn cards(session: &Session, ids: &[String]) -> Result> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + let request = BatchedEntityRequest { + entity_request: ids + .iter() + .map(|id| EntityRequest { + entity_uri: format!("{ARTIST_PREFIX}{id}"), + query: vec![ExtensionQuery { + extension_kind: EnumOrUnknown::new(ExtensionKind::ARTIST_V4), + ..Default::default() + }], + ..Default::default() + }) + .collect(), + ..Default::default() + }; + + let response = session + .spclient() + .get_extended_metadata(request) + .await + .context("cannot read artist portraits")?; + + let mut found = HashMap::new(); + for array in response.extended_metadata { + for entity in array.extension_data { + let Ok(message) = ArtistMessage::parse_from_bytes(&entity.extension_data.value) else { + continue; + }; + let Some(id) = entity.entity_uri.strip_prefix(ARTIST_PREFIX) else { + continue; + }; + let smallest = portraits(&message).into_iter().min_by_key(|image| image_width(image)); + + found.insert( + id.to_owned(), + SavedArtist { + id: id.to_owned(), + name: match message.name() { + "" => UNKNOWN.to_owned(), + name => name.to_owned(), + }, + cover: smallest.and_then(|image| wire::image_url(image.file_id())), + added_at: None, + }, + ); + } + } + + Ok(found) +} + +fn artist_from(artist: &ArtistMessage, top_tracks: Vec, albums: Vec) -> Artist { + let profile = profile_from(artist); + + Artist { + name: profile.name, + cover_large: profile.cover_large, + biography: profile.biography, + monthly_listeners: None, + top_tracks, + albums, + } +} + +fn profile_from(artist: &ArtistMessage) -> ArtistProfile { + let portraits = portraits(artist); + + ArtistProfile { + name: artist.name().to_owned(), + cover_large: portraits + .iter() + .filter(|image| image_width(image) >= LARGE_PORTRAIT) + .min_by_key(|image| image_width(image)) + .or_else(|| portraits.iter().max_by_key(|image| image_width(image))) + .and_then(|image| wire::image_url(image.file_id())), + biography: artist.biography.iter().find_map(|bio| { + bio.text + .as_deref() + .filter(|text| !text.is_empty()) + .map(plain_text) + .filter(|text| !text.is_empty()) + }), + } +} + +fn plain_text(html: &str) -> String { + let mut text = String::with_capacity(html.len()); + let mut tag = String::new(); + let mut inside_tag = false; + + for character in html.chars() { + match character { + '<' if !inside_tag => { + inside_tag = true; + tag.clear(); + } + '>' if inside_tag => { + inside_tag = false; + let tag = tag.trim().to_ascii_lowercase(); + if (tag.starts_with("br") + || tag.starts_with("/p") + || tag.starts_with("/li") + || tag.starts_with("/div")) + && !text.ends_with(char::is_whitespace) + { + text.push(' '); + } + } + _ if inside_tag => tag.push(character), + _ => text.push(character), + } + } + + let decoded = text + .replace(" ", " ") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">"); + + decoded.split_whitespace().collect::>().join(" ") +} + +fn top_track_uris(artist: &ArtistMessage, country: &str) -> Vec { + artist + .top_track + .iter() + .find(|tracks| tracks.country() == country) + .or_else(|| artist.top_track.iter().find(|tracks| tracks.country().is_empty())) + .into_iter() + .flat_map(|tracks| tracks.track.iter()) + .filter_map(|track| collection::base62(track.gid())) + .map(|id| format!("{TRACK_PREFIX}{id}")) + .collect() +} + +fn release_uris(artist: &ArtistMessage) -> Vec { + let mut seen = HashSet::new(); + + artist + .album_group + .iter() + .chain(artist.single_group.iter()) + .chain(artist.compilation_group.iter()) + .flat_map(|group| group.album.iter()) + .filter_map(|album| collection::base62(album.gid())) + .filter(|id| seen.insert(id.clone())) + .map(|id| format!("{ALBUM_PREFIX}{id}")) + .collect() +} + +fn portraits(artist: &ArtistMessage) -> Vec<&Image> { + let mut portraits: Vec<_> = artist + .portrait_group + .as_ref() + .into_iter() + .flat_map(|group| group.image.iter()) + .filter(|image| image.has_file_id()) + .collect(); + portraits.extend(artist.portrait.iter().filter(|image| image.has_file_id())); + portraits +} + +fn image_width(image: &Image) -> i32 { + if image.width() > 0 { + return image.width(); + } + + match image.size() { + ImageSize::SMALL => 64, + ImageSize::DEFAULT => 300, + ImageSize::LARGE => 640, + ImageSize::XLARGE => 1_000, + } +} + +#[cfg(test)] +mod tests { + use super::{Album, newest_first, plain_text}; + use crate::ReleaseType; + + fn release(name: &str, date: &str, release_type: ReleaseType) -> Album { + Album { + id: name.to_lowercase(), + name: name.to_owned(), + artists: String::new(), + artist_refs: Vec::new(), + cover: None, + cover_large: None, + release_type, + year: 0, + track_count: 0, + release_date: date.to_owned(), + label: String::new(), + copyrights: Vec::new(), + added_at: None, + } + } + + #[test] + fn every_edition_survives_but_market_duplicates_do_not() { + let releases = newest_first( + [ + release("Meteora (Bonus Edition)", "2003-03-25", ReleaseType::Album), + release("Meteora", "2003-03-25", ReleaseType::Album), + release("METEORA", "2003-03-25", ReleaseType::Album), + release("Meteora", "2003-03-25", ReleaseType::Single), + release("From Zero", "2024-11-15", ReleaseType::Album), + ] + .into_iter(), + ); + let names: Vec<_> = + releases.iter().map(|album| (album.name.as_str(), album.release_type)).collect(); + + assert_eq!( + names, + [ + ("From Zero", ReleaseType::Album), + ("Meteora (Bonus Edition)", ReleaseType::Album), + ("Meteora", ReleaseType::Album), + ("Meteora", ReleaseType::Single), + ] + ); + } + + #[test] + fn biography_html_becomes_readable_text() { + assert_eq!( + plain_text( + "Formed by Alice & Bob.
Based in Paris." + ), + "Formed by Alice & Bob. Based in Paris." + ); + } +} diff --git a/crates/spotify/src/auth.rs b/crates/spotify/src/auth.rs new file mode 100644 index 0000000..06f4a01 --- /dev/null +++ b/crates/spotify/src/auth.rs @@ -0,0 +1,355 @@ +//! Spotify sign-in. Adapted from nolight132/sonora `spotify/auth.rs`. +//! +//! Change from Sonora: Sonora used `librespot_oauth::OAuthClientBuilder`, which either opens a +//! browser itself or prints the authorization URL to stdout and blocks. A daemon can do neither, so +//! the PKCE authorization-code flow is driven here (via the `oauth2` crate librespot-oauth wraps): +//! the authorization URL is handed to a caller-supplied sink, and the loopback redirect is caught by +//! a small one-shot listener. Everything else — the credential cache, the Premium gate, the error +//! classification — is Sonora's, near-verbatim. + +use std::path::PathBuf; + +use anyhow::{Context as _, Result, anyhow}; +use librespot_core::authentication::Credentials; +use librespot_core::cache::Cache; +use librespot_core::{Session, SessionConfig}; +use oauth2::basic::BasicClient; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, + TokenResponse, TokenUrl, +}; + +use crate::credentials; + +pub const DEFAULT_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +pub const DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login"; + +const AUTHORIZE_URL: &str = "https://accounts.spotify.com/authorize"; +const TOKEN_URL: &str = "https://accounts.spotify.com/api/token"; + +/// How long the browser gets to come back with the redirect before the flow gives up. +const REDIRECT_WAIT: std::time::Duration = std::time::Duration::from_secs(300); +const PRODUCT_WAIT: std::time::Duration = std::time::Duration::from_secs(5); +const PRODUCT_POLL: std::time::Duration = std::time::Duration::from_millis(100); + +pub const SCOPES: &[&str] = &[ + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "user-follow-read", + "user-library-read", + "user-read-email", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", +]; + +/// Why a sign-in failed, when the reason is one the caller can act on (as opposed to a raw transport +/// error). Lifted from Sonora's `music` crate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignInProblem { + Premium, + Region, + Credentials, + Network, + Cancelled, + Refused, +} + +/// A [`SignInProblem`] carried as an [`std::error::Error`] so it can ride inside `anyhow` and be +/// recovered with `downcast_ref`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SignInFailure(pub SignInProblem); + +impl std::fmt::Display for SignInFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let reason = match self.0 { + SignInProblem::Premium => "the account has no Spotify Premium", + SignInProblem::Region => "the account is out of its home region", + SignInProblem::Credentials => "the stored credentials are no longer valid", + SignInProblem::Network => "Spotify could not be reached", + SignInProblem::Cancelled => "authorization was cancelled in the browser", + SignInProblem::Refused => "Spotify refused the session", + }; + write!(f, "{reason}") + } +} + +impl std::error::Error for SignInFailure {} + +#[derive(Clone, Debug)] +pub struct AuthConfig { + pub client_id: String, + pub redirect_uri: String, + pub cache_dir: PathBuf, +} + +impl AuthConfig { + /// The stored credential librespot writes after a successful connect. + pub fn file(&self) -> PathBuf { + self.cache_dir.join(credentials::FILE) + } + + /// The per-installation Spotify device id file, next to the credentials. + fn device_file(&self) -> PathBuf { + self.cache_dir.join(credentials::DEVICE_FILE) + } +} + +/// The loopback authority (`host:port`) a redirect URI redirects to, so the callback listener knows +/// where to bind. +fn socket_address(uri: &str) -> Option { + let rest = uri.strip_prefix("http://").or_else(|| uri.strip_prefix("https://"))?; + let authority = rest.split('/').next().filter(|host| !host.is_empty())?; + match authority.rsplit_once(':') { + Some((_, port)) if port.chars().all(|digit| digit.is_ascii_digit()) => { + Some(authority.to_owned()) + } + _ => Some(format!("{authority}:80")), + } +} + +pub async fn restore(config: &AuthConfig) -> Result> { + let session = session(config)?; + let Some(credentials) = session.cache().and_then(|cache| cache.credentials()) else { + return Ok(None); + }; + + session.connect(credentials, true).await.map_err(denied)?; + credentials::secure(&config.file()); + premium(&session).await?; + Ok(Some(session)) +} + +/// Run the PKCE authorization-code flow. `on_url` receives the authorization URL the moment it is +/// known — the daemon hands it to the client to open — then this blocks on the loopback redirect and +/// exchanges the code for a session. +pub async fn sign_in(config: &AuthConfig, on_url: F) -> Result +where + F: FnOnce(String) + Send + 'static, +{ + let access_token = authorize(config, on_url).await?; + + let session = session(config)?; + session.connect(Credentials::with_access_token(access_token), true).await.map_err(denied)?; + credentials::secure(&config.file()); + premium(&session).await?; + Ok(session) +} + +async fn authorize(config: &AuthConfig, on_url: F) -> Result +where + F: FnOnce(String) + Send + 'static, +{ + let address = socket_address(&config.redirect_uri) + .ok_or_else(|| anyhow!("cannot read a socket address from {}", config.redirect_uri))?; + + let client = BasicClient::new(ClientId::new(config.client_id.clone())) + .set_auth_uri(AuthUrl::new(AUTHORIZE_URL.to_owned()).context("invalid authorize URL")?) + .set_token_uri(TokenUrl::new(TOKEN_URL.to_owned()).context("invalid token URL")?) + .set_redirect_uri( + RedirectUrl::new(config.redirect_uri.clone()) + .with_context(|| format!("invalid redirect URI {}", config.redirect_uri))?, + ); + + let (challenge, verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, _csrf) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(SCOPES.iter().map(|scope| Scope::new((*scope).to_owned()))) + .set_pkce_challenge(challenge) + .url(); + + on_url(auth_url.to_string()); + + let code = tokio::time::timeout(REDIRECT_WAIT, listen(&address)) + .await + .map_err(|_| anyhow::Error::new(SignInFailure(SignInProblem::Cancelled))) + .context("no Spotify redirect arrived in time")??; + + let http = reqwest::Client::new(); + let token = client + .exchange_code(AuthorizationCode::new(code)) + .set_pkce_verifier(verifier) + .request_async(&http) + .await + .map_err(|error| anyhow!("Spotify token exchange failed: {error}"))?; + + Ok(token.access_token().secret().to_owned()) +} + +/// Wait for a single loopback redirect, mimicking Spotify's own client: bind the redirect socket, +/// accept one connection, pull `code` out of the request target, and reply with a close-me message. +/// Async so the caller's deadline can drop it; a blocking accept would hold the port forever and +/// make every later sign-in a silent no-op. +async fn listen(address: &str) -> Result { + use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; + + let listener = tokio::net::TcpListener::bind(address) + .await + .with_context(|| format!("cannot bind the OAuth callback listener on {address}"))?; + log::info!("auth: OAuth callback listening on {address}"); + + let (stream, _) = listener.accept().await.context("the OAuth callback listener failed")?; + let mut stream = BufReader::new(stream); + + let mut request_line = String::new(); + stream.read_line(&mut request_line).await.context("cannot read the OAuth redirect request")?; + + let target = request_line + .split_whitespace() + .nth(1) + .context("the OAuth redirect request had no target")?; + let redirect = format!("http://localhost{target}"); + + let message = "Ryotunes has your Spotify authorization. You can close this tab."; + let response = + format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", message.len(), message); + let _ = stream.get_mut().write_all(response.as_bytes()).await; + + if let Some(code) = query_param(&redirect, "code") { + return Ok(code); + } + match query_param(&redirect, "error").as_deref() { + Some("access_denied") => Err(anyhow::Error::new(SignInFailure(SignInProblem::Cancelled))), + Some(code) => Err(anyhow!("Spotify refused authorization ({code})")), + None => Err(anyhow!("the OAuth redirect carried no authorization code")), + } +} + +fn query_param(redirect_url: &str, key: &str) -> Option { + let url = url::Url::parse(redirect_url).ok()?; + url.query_pairs().find(|(name, _)| name == key).map(|(_, value)| value.into_owned()) +} + +async fn premium(session: &Session) -> Result<()> { + let deadline = tokio::time::Instant::now() + PRODUCT_WAIT; + loop { + if let Some(account) = session.user_data().attributes.get("type") { + match account.as_str() { + "premium" => return Ok(()), + _ => { + session.shutdown(); + return Err(anyhow::Error::new(SignInFailure(SignInProblem::Premium))); + } + } + } + if tokio::time::Instant::now() >= deadline { + return Ok(()); + } + tokio::time::sleep(PRODUCT_POLL).await; + } +} + +fn denied(error: librespot_core::Error) -> anyhow::Error { + let problem = classify(&error.to_string()); + anyhow::Error::new(error).context(SignInFailure(problem)) +} + +fn classify(message: &str) -> SignInProblem { + let message = message.to_lowercase(); + if message.contains("travel restriction") { + return SignInProblem::Region; + } + if message.contains("bad credentials") || message.contains("invalid credentials") { + return SignInProblem::Credentials; + } + if message.contains("connection") + || message.contains("timed out") + || message.contains("dns") + || message.contains("network") + { + return SignInProblem::Network; + } + SignInProblem::Refused +} + +pub fn forget(config: &AuthConfig) { + credentials::remove(&config.file()); + // The device id is kept: it identifies this installation, not this account, and reusing it + // after a sign-out/sign-in is what keeps the new credentials attached to a known device. +} + +fn session(config: &AuthConfig) -> Result { + let cache = Cache::new(Some(config.cache_dir.as_path()), None, None, None) + .with_context(|| format!("cannot open cache at {}", config.cache_dir.display()))?; + + let session_config = SessionConfig { + client_id: config.client_id.clone(), + device_id: device_id(config), + ..Default::default() + }; + + Ok(Session::new(session_config, Some(cache))) +} + +/// The device identity librespot registers with Spotify. librespot's default mints a fresh +/// random uuid per process; a daemon that restarts on every launch (socket activation, idle +/// exit) then re-registers a "new device" each time, which Spotify's session/credential +/// handling treats as a different device — part of why restore kept demanding a fresh sign-in. +/// Persisting one id per installation keeps the stored credentials tied to the device that +/// earned them. +fn device_id(config: &AuthConfig) -> String { + let path = config.device_file(); + if let Ok(stored) = std::fs::read_to_string(&path) { + let id = stored.trim(); + if is_valid_device_id(id) { + return id.to_owned(); + } + } + let id = uuid::Uuid::new_v4().as_hyphenated().to_string(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + match std::fs::write(&path, &id) { + Ok(()) => { + credentials::secure(&path); + log::info!("auth: generated a persistent Spotify device id"); + } + Err(e) => { + log::warn!("auth: cannot persist the device id, using a per-process one: {e}"); + } + } + id +} + +/// A uuid-shaped id, no longer than a uuid, nothing but hex and hyphens. Guards against a +/// truncated or corrupt device file being handed to Spotify as the device identity. +fn is_valid_device_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 36 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +#[cfg(test)] +mod tests { + use super::{query_param, socket_address}; + + #[test] + fn reads_host_and_port() { + assert_eq!( + socket_address("http://127.0.0.1:8989/login").as_deref(), + Some("127.0.0.1:8989") + ); + } + + #[test] + fn defaults_the_port_when_absent() { + assert_eq!(socket_address("http://localhost/login").as_deref(), Some("localhost:80")); + } + + #[test] + fn pulls_the_code_from_a_redirect() { + assert_eq!( + query_param("http://localhost/login?code=abc123&state=x", "code").as_deref(), + Some("abc123") + ); + } + + #[test] + fn reads_a_cancelled_error() { + assert_eq!( + query_param("http://localhost/login?error=access_denied", "error").as_deref(), + Some("access_denied") + ); + assert_eq!(query_param("http://localhost/login?error=access_denied", "code"), None); + } +} diff --git a/crates/spotify/src/client.rs b/crates/spotify/src/client.rs new file mode 100644 index 0000000..53989c4 --- /dev/null +++ b/crates/spotify/src/client.rs @@ -0,0 +1,319 @@ +//! The signed-in Spotify client. Adapted from nolight132/sonora `spotify/client.rs`: Sonora's +//! `impl MusicApi for LibrespotClient` becomes inherent async methods on [`Client`] (Ryotunes has no +//! `MusicApi` trait), and the client now owns the one librespot player/FIFO ([`stream::Engine`]) that +//! streams every track into mpv. + +use std::collections::HashSet; + +use anyhow::{Context as _, Result}; +use librespot_core::Session; +use librespot_protocol::playlist4_external::SelectedListContent as RootList; +use protobuf::Message as _; + +use crate::stream::{self, StreamHandle}; +use crate::{ + Album, AlbumDetail, Artist, ArtistProfile, ArtistRef, Genre, GenreDetail, HomeFeed, Lyrics, + MediaKind, Playlist, PlaylistDetail, SavedArtist, Track, UserDetail, UserProfile, albums, + artists, collection, collection2, lyrics, pathfinder, playlists, profiles, radio, search, wire, +}; + +const MADE_FOR_YOU: &str = "0JQ5DAt0tbjZptfcdMSKl3"; +/// How many liked tracks make up one [`Client::liked`] page. +const LIKED_PAGE: u32 = 100; + +/// The four result lists a query returns. `artists` is derived from the track hits' artist refs +/// (Spotify's Pathfinder search covers albums and playlists but not artists); see the README. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SearchResults { + pub tracks: Vec, + pub albums: Vec, + pub artists: Vec, + pub playlists: Vec, +} + +pub struct Client { + session: Session, + engine: stream::Engine, +} + +impl Client { + pub(crate) fn new(session: Session) -> Result { + let engine = stream::Engine::new(session.clone()) + .context("cannot start the Spotify playback engine")?; + Ok(Self { session, engine }) + } + + /// The underlying librespot session, for callers that need it directly. + pub fn session(&self) -> &Session { + &self.session + } + + /// Whether the session is still connected. + pub fn alive(&self) -> bool { + !self.session.is_invalid() + } + + /// A public `open.spotify.com` URL for a piece of media. + pub fn share_url(&self, kind: MediaKind, id: &str) -> String { + let kind = match kind { + MediaKind::Track => "track", + MediaKind::Album => "album", + MediaKind::Artist => "artist", + MediaKind::Playlist => "playlist", + }; + format!("https://open.spotify.com/{kind}/{id}") + } + + // -- Playback ----------------------------------------------------------------------------- + + /// Start streaming `track_id` (bare id or `spotify:track:` URI) through the FIFO mpv reads. + pub fn stream(&self, track_id: &str) -> Result { + self.engine.stream(track_id) + } + + // -- Profile / library -------------------------------------------------------------------- + + pub async fn profile(&self) -> Result { + let username = self.session.username(); + let body = self.session.spclient().get_user_profile(&username, None, None).await?; + + let profile: wire::Named = serde_json::from_slice(&body).unwrap_or_default(); + Ok(UserProfile { + display_name: profile.label().unwrap_or(&username).to_owned(), + id: username, + }) + } + + pub async fn user(&self, user_id: &str) -> Result { + profiles::profile(&self.session, user_id).await + } + + /// One page of the user's liked (saved) tracks, newest first. Spotify's collection API pages by + /// an opaque continuation token rather than an offset, so page `n` is served by reading the first + /// `(n + 1) * LIKED_PAGE` saved tracks and returning the tail slice. + pub async fn liked(&self, page: u32) -> Result> { + let limit = page.saturating_add(1).saturating_mul(LIKED_PAGE); + let mut tracks = collection::saved_tracks(&self.session, limit).await?; + let skip = (page.saturating_mul(LIKED_PAGE)) as usize; + if skip >= tracks.len() { + return Ok(Vec::new()); + } + Ok(tracks.split_off(skip)) + } + + pub async fn saved_tracks(&self, limit: u32) -> Result> { + collection::saved_tracks(&self.session, limit).await + } + + pub async fn set_track_saved(&self, track_id: &str, saved: bool) -> Result<()> { + collection2::set_track_saved(&self.session, track_id, saved).await + } + + pub async fn saved_albums(&self, limit: u32) -> Result> { + albums::saved_albums(&self.session, limit).await + } + + pub async fn set_album_saved(&self, album_id: &str, saved: bool) -> Result<()> { + collection2::set_album_saved(&self.session, album_id, saved).await + } + + pub async fn saved_artists(&self, limit: u32) -> Result> { + artists::saved_artists(&self.session, limit).await + } + + pub async fn set_artist_saved(&self, artist_id: &str, saved: bool) -> Result<()> { + collection2::set_artist_saved(&self.session, artist_id, saved).await + } + + // -- Tracks / albums / artists ------------------------------------------------------------ + + pub async fn track(&self, track_id: &str) -> Result { + collection::track(&self.session, track_id).await + } + + pub async fn track_playcount(&self, track_id: &str) -> Result> { + pathfinder::track(&self.session, track_id).await + } + + pub async fn track_radio(&self, track_id: &str) -> Result> { + radio::track_radio(&self.session, track_id).await + } + + pub async fn lyrics(&self, track_id: &str) -> Result> { + lyrics::lyrics(&self.session, track_id).await + } + + pub async fn album(&self, album_id: &str) -> Result { + albums::album(&self.session, album_id).await + } + + pub async fn album_tracks(&self, album_id: &str) -> Result> { + albums::album_tracks(&self.session, album_id).await + } + + pub async fn artist(&self, artist_id: &str) -> Result { + artists::artist(&self.session, artist_id).await + } + + pub async fn artist_profile(&self, artist_id: &str) -> Result { + artists::profile(&self.session, artist_id).await + } + + pub async fn artist_images( + &self, + ids: Vec, + ) -> Result> { + artists::images(&self.session, &ids).await + } + + // -- Playlists ---------------------------------------------------------------------------- + + pub async fn playlist(&self, playlist_id: &str) -> Result { + let mut detail = playlists::playlist(&self.session, playlist_id).await?; + let owner = detail.playlist.owner_id.clone(); + if !owner.is_empty() { + let names = + profiles::display_names(&self.session, HashSet::from([owner.clone()])).await; + if let Some(name) = names.get(&owner) { + detail.playlist.owner = name.clone(); + } + } + Ok(detail) + } + + pub async fn playlist_tracks(&self, playlist_id: &str) -> Result> { + playlists::playlist_tracks(&self.session, playlist_id).await + } + + pub async fn playlist_covers(&self, playlist_id: &str, wanted: usize) -> Result> { + playlists::covers(&self.session, playlist_id, wanted).await + } + + pub async fn playlists(&self, limit: u32) -> Result> { + let body = self.session.spclient().get_rootlist(0, Some(limit as usize)).await?; + + let rootlist = + RootList::parse_from_bytes(&body).context("cannot decode the rootlist protobuf")?; + let mut playlists = wire::playlists_from(&rootlist); + + let owners = playlists + .iter() + .map(|playlist| playlist.owner_id.clone()) + .filter(|owner| !owner.is_empty()) + .collect(); + let ids = playlists.iter().map(|playlist| playlist.id.clone()).collect(); + let (names, stamps) = tokio::join!( + profiles::display_names(&self.session, owners), + playlists::modified(&self.session, ids) + ); + + for playlist in &mut playlists { + playlist.owned = playlist.owner_id == self.session.username(); + if let Some(name) = names.get(&playlist.owner_id) { + playlist.owner = name.clone(); + } + playlist.modified_at = stamps.get(&playlist.id).copied(); + } + + Ok(playlists) + } + + pub async fn create_playlist(&self, name: &str) -> Result { + playlists::create(&self.session, name).await + } + + pub async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<()> { + playlists::rename(&self.session, playlist_id, name).await + } + + pub async fn delete_playlist(&self, playlist_id: &str) -> Result<()> { + playlists::delete(&self.session, playlist_id).await + } + + pub async fn remove_playlist_from_library(&self, playlist_id: &str) -> Result<()> { + playlists::remove_from_library(&self.session, playlist_id).await + } + + pub async fn add_playlist_to_library(&self, playlist_id: &str) -> Result<()> { + playlists::add_to_library(&self.session, playlist_id).await + } + + pub async fn set_playlist_public(&self, playlist_id: &str, public: bool) -> Result<()> { + playlists::set_public(&self.session, playlist_id, public).await + } + + pub async fn add_track_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { + playlists::add_track(&self.session, playlist_id, track_id).await + } + + pub async fn remove_track_from_playlist( + &self, + playlist_id: &str, + track_id: &str, + ) -> Result<()> { + playlists::remove_track(&self.session, playlist_id, track_id).await + } + + // -- Discovery ---------------------------------------------------------------------------- + + /// Search tracks, albums, and playlists in one shot; artists are derived from the track hits. A + /// track failure propagates; album/playlist failures degrade to empty (a stale Pathfinder hash + /// should not sink the whole query). + pub async fn search(&self, query: &str) -> Result { + let (tracks, albums, playlists) = tokio::join!( + search::search(&self.session, query), + pathfinder::search_albums(&self.session, query), + pathfinder::search_playlists(&self.session, query), + ); + + let tracks = tracks?; + let albums = albums.unwrap_or_else(|error| { + log::warn!("search: cannot search albums: {error:#}"); + Vec::new() + }); + let playlists = playlists.unwrap_or_else(|error| { + log::warn!("search: cannot search playlists: {error:#}"); + Vec::new() + }); + let artists = artists_of(&tracks); + + Ok(SearchResults { tracks, albums, artists, playlists }) + } + + pub async fn search_albums(&self, query: &str) -> Result> { + pathfinder::search_albums(&self.session, query).await + } + + pub async fn search_playlists(&self, query: &str) -> Result> { + pathfinder::search_playlists(&self.session, query).await + } + + pub async fn home(&self) -> Result { + let mut sections = pathfinder::genre(&self.session, MADE_FOR_YOU).await?.sections; + playlists::name_blanks(&self.session, &mut sections).await; + + Ok(HomeFeed { sections, ..HomeFeed::default() }) + } + + pub async fn genres(&self) -> Result> { + pathfinder::genres(&self.session).await + } + + pub async fn genre(&self, genre_id: &str) -> Result { + pathfinder::genre(&self.session, genre_id).await + } +} + +/// The distinct artists across a set of track hits, in first-seen order — Spotify search has no +/// artist entity, so results surface the artists that its tracks credit. +fn artists_of(tracks: &[Track]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut artists = Vec::new(); + for artist in tracks.iter().flat_map(|track| track.artist_refs.iter()) { + let key = artist.id.clone().unwrap_or_else(|| artist.name.clone()); + if seen.insert(key) { + artists.push(artist.clone()); + } + } + artists +} diff --git a/crates/spotify/src/collection.rs b/crates/spotify/src/collection.rs new file mode 100644 index 0000000..fe92573 --- /dev/null +++ b/crates/spotify/src/collection.rs @@ -0,0 +1,177 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use librespot_core::Session; +use librespot_protocol::entity_extension_data::EntityExtensionData; +use librespot_protocol::extended_metadata::{BatchedEntityRequest, EntityRequest, ExtensionQuery}; +use librespot_protocol::extension_kind::ExtensionKind; +use librespot_protocol::metadata::image::Size as ImageSize; +use librespot_protocol::metadata::{ + Album as AlbumMessage, Artist as ArtistMessage, Track as TrackMessage, +}; +use protobuf::{EnumOrUnknown, Message as _}; + +use crate::{ArtistRef, Credit, Track}; +use crate::{collection2, wire}; + +const TRACK_PREFIX: &str = "spotify:track:"; +const UNKNOWN: &str = "Unknown"; +const BATCH: usize = 500; + +pub async fn saved_tracks(session: &Session, limit: u32) -> Result> { + let items = + collection2::saved_items(session, collection2::COLLECTION, TRACK_PREFIX, limit as usize) + .await?; + if items.is_empty() { + return Ok(Vec::new()); + } + + let uris: Vec<_> = items.iter().map(|item| item.uri.clone()).collect(); + let mut known = metadata(session, &uris).await?; + Ok(items + .into_iter() + .filter_map(|item| { + let mut track = known.remove(&item.uri)?; + track.added_at = item.added_at; + Some(track) + }) + .collect()) +} + +pub async fn track(session: &Session, track_id: &str) -> Result { + let uri = format!("{TRACK_PREFIX}{track_id}"); + metadata(session, std::slice::from_ref(&uri)) + .await? + .remove(&uri) + .context("track metadata is missing") +} + +pub(crate) async fn metadata(session: &Session, uris: &[String]) -> Result> { + let entities = extended(session, uris, ExtensionKind::TRACK_V4) + .await + .context("cannot read track metadata")?; + + let mut tracks = HashMap::new(); + for entity in entities { + let Ok(message) = TrackMessage::parse_from_bytes(&entity.extension_data.value) else { + continue; + }; + let track = track_from(&entity.entity_uri, &message); + tracks.insert(entity.entity_uri, track); + } + Ok(tracks) +} + +pub(crate) async fn extended( + session: &Session, + uris: &[String], + kind: ExtensionKind, +) -> Result> { + let mut entities = Vec::with_capacity(uris.len()); + for batch in uris.chunks(BATCH) { + let request = BatchedEntityRequest { + entity_request: batch + .iter() + .map(|uri| EntityRequest { + entity_uri: uri.clone(), + query: vec![ExtensionQuery { + extension_kind: EnumOrUnknown::new(kind), + ..Default::default() + }], + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let response = session.spclient().get_extended_metadata(request).await?; + entities + .extend(response.extended_metadata.into_iter().flat_map(|array| array.extension_data)); + } + Ok(entities) +} + +fn track_from(uri: &str, track: &TrackMessage) -> Track { + let (artists, artist_refs) = artists_from(&track.artist); + + Track { + id: uri.strip_prefix(TRACK_PREFIX).map(str::to_owned), + playable: !track.file.is_empty() || !track.alternative.is_empty(), + name: non_empty(track.name.as_deref()).unwrap_or(UNKNOWN).to_owned(), + artists, + artist_refs, + album: track + .album + .as_ref() + .and_then(|album| non_empty(album.name.as_deref())) + .unwrap_or_default() + .to_owned(), + album_id: track.album.as_ref().and_then(|album| base62(album.gid())), + cover: track.album.as_ref().and_then(cover_url), + duration: Duration::from_millis(track.duration.unwrap_or_default().max(0) as u64), + added_at: None, + added_by: None, + playcount: None, + popularity: track.popularity.unwrap_or_default().clamp(0, 100) as u32, + explicit: track.explicit.unwrap_or_default(), + track_number: track.number.unwrap_or_default().max(0) as u32, + disc_number: track.disc_number.unwrap_or_default().max(0) as u32, + tags: track.tags.clone(), + languages: track.language_of_performance.clone(), + credits: track + .artist_with_role + .iter() + .filter_map(|credit| { + let name = non_empty(credit.artist_name.as_deref())?.to_owned(); + let role = match credit.role.map(|role| role.value()) { + Some(1) => "Main artist", + Some(2) => "Featured artist", + Some(3) => "Remixer", + Some(4) => "Actor", + Some(5) => "Composer", + Some(6) => "Conductor", + Some(7) => "Orchestra", + _ => "Performer", + }; + Some(Credit { name, role: role.to_owned(), id: base62(credit.artist_gid()) }) + }) + .collect(), + } +} + +pub(crate) fn artists_from(artists: &[ArtistMessage]) -> (String, Vec) { + let refs: Vec<_> = artists + .iter() + .filter_map(|artist| { + let name = non_empty(artist.name.as_deref())?.to_owned(); + Some(ArtistRef { name, id: base62(artist.gid()) }) + }) + .collect(); + let names = match refs.is_empty() { + true => UNKNOWN.to_owned(), + false => refs.iter().map(|artist| artist.name.as_str()).collect::>().join(", "), + }; + (names, refs) +} + +pub(crate) fn base62(gid: &[u8]) -> Option { + librespot_core::SpotifyId::from_raw(gid).ok()?.to_base62().ok() +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} + +fn cover_url(album: &AlbumMessage) -> Option { + let smallest = + album.cover_group.as_ref()?.image.iter().filter(|image| image.has_file_id()).min_by_key( + |image| match image.size() { + ImageSize::SMALL => 0, + ImageSize::DEFAULT => 1, + ImageSize::LARGE => 2, + ImageSize::XLARGE => 3, + }, + )?; + + wire::image_url(smallest.file_id()) +} diff --git a/crates/spotify/src/collection2.rs b/crates/spotify/src/collection2.rs new file mode 100644 index 0000000..61564a9 --- /dev/null +++ b/crates/spotify/src/collection2.rs @@ -0,0 +1,221 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context as _, Result}; +use http::header::{ACCEPT, CONTENT_TYPE}; +use http::{HeaderMap, HeaderValue, Method}; +use librespot_core::Session; + +use crate::pb::{Reader, Value, Writer, text}; + +const PAGING: &str = "/collection/v2/paging"; +const WRITE: &str = "/collection/v2/write"; +const CONTENT: &str = "application/vnd.collection-v2.spotify.proto"; +pub(crate) const COLLECTION: &str = "collection"; +pub(crate) const ARTISTS: &str = "artist"; +const PAGE: i32 = 300; + +static UPDATE: AtomicU64 = AtomicU64::new(0); + +const REQUEST_USERNAME: u32 = 1; +const REQUEST_SET: u32 = 2; +const REQUEST_TOKEN: u32 = 3; +const REQUEST_LIMIT: u32 = 4; + +const RESPONSE_ITEMS: u32 = 1; +const RESPONSE_TOKEN: u32 = 2; + +const ITEM_URI: u32 = 1; +const ITEM_ADDED_AT: u32 = 2; +const ITEM_REMOVED: u32 = 3; + +const WRITE_USERNAME: u32 = 1; +const WRITE_SET: u32 = 2; +const WRITE_ITEMS: u32 = 3; +const WRITE_UPDATE_ID: u32 = 4; + +struct Page { + items: Vec, + next: String, +} + +pub(crate) struct SavedItem { + pub(crate) uri: String, + pub(crate) added_at: Option, +} + +pub async fn set_track_saved(session: &Session, track_id: &str, saved: bool) -> Result<()> { + set_saved(session, COLLECTION, &format!("spotify:track:{track_id}"), saved).await +} + +pub async fn set_album_saved(session: &Session, album_id: &str, saved: bool) -> Result<()> { + set_saved(session, COLLECTION, &format!("spotify:album:{album_id}"), saved).await +} + +pub async fn set_artist_saved(session: &Session, artist_id: &str, saved: bool) -> Result<()> { + set_saved(session, ARTISTS, &format!("spotify:artist:{artist_id}"), saved).await +} + +async fn set_saved(session: &Session, set: &str, uri: &str, saved: bool) -> Result<()> { + let now = + SystemTime::now().duration_since(UNIX_EPOCH).context("cannot read the current time")?; + let added_at = match saved { + true => now + .as_secs() + .try_into() + .context("current time exceeds the collection timestamp range")?, + false => 0, + }; + + let mut item = Writer::default(); + item.string(ITEM_URI, uri); + item.int32(ITEM_ADDED_AT, added_at); + item.bool(ITEM_REMOVED, !saved); + + let sequence = UPDATE.fetch_add(1, Ordering::Relaxed); + let update_id = format!( + "sonora-{}-{}-{}-{sequence}", + now.as_secs(), + now.subsec_nanos(), + std::process::id() + ); + let mut request = Writer::default(); + request.string(WRITE_USERNAME, &session.username()); + request.string(WRITE_SET, set); + request.message(WRITE_ITEMS, &item.finish()); + request.string(WRITE_UPDATE_ID, &update_id); + let body = request.finish(); + + session + .spclient() + .request(&Method::POST, WRITE, Some(headers()), Some(&body)) + .await + .context("cannot update the saved collection")?; + Ok(()) +} + +pub(crate) async fn saved_items( + session: &Session, + set: &str, + prefix: &str, + limit: usize, +) -> Result> { + let username = session.username(); + let mut found = Vec::new(); + let mut token = String::new(); + + loop { + let body = request(&username, set, &token); + let raw = session + .spclient() + .request(&Method::POST, PAGING, Some(headers()), Some(&body)) + .await + .context("cannot read the saved collection")?; + + let page = response(&raw).context("cannot decode the collection page")?; + + found.extend(page.items.into_iter().filter(|item| item.uri.starts_with(prefix))); + + token = page.next; + if found.len() >= limit || token.is_empty() { + break; + } + } + + found.truncate(limit); + Ok(found) +} + +fn headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(CONTENT)); + headers.insert(ACCEPT, HeaderValue::from_static(CONTENT)); + headers +} + +fn request(username: &str, set: &str, token: &str) -> Vec { + let mut writer = Writer::default(); + writer.string(REQUEST_USERNAME, username); + writer.string(REQUEST_SET, set); + writer.string(REQUEST_TOKEN, token); + writer.int32(REQUEST_LIMIT, PAGE); + writer.finish() +} + +fn response(bytes: &[u8]) -> Result { + let mut page = Page { items: Vec::new(), next: String::new() }; + let mut reader = Reader::new(bytes); + + while let Some((field, value)) = reader.field()? { + match (field, value) { + (RESPONSE_ITEMS, Value::Bytes(item)) => page.items.extend(kept(item)?), + (RESPONSE_TOKEN, Value::Bytes(token)) => page.next = text(token)?, + _ => (), + } + } + + Ok(page) +} + +fn kept(bytes: &[u8]) -> Result> { + let mut uri = None; + let mut added_at = None; + let mut removed = false; + let mut reader = Reader::new(bytes); + + while let Some((field, value)) = reader.field()? { + match (field, value) { + (ITEM_URI, Value::Bytes(raw)) => uri = Some(text(raw)?), + (ITEM_ADDED_AT, Value::Varint(raw) | Value::Fixed(raw)) => { + added_at = Some(raw as u32 as i32 as i64); + } + (ITEM_REMOVED, Value::Varint(flag)) => removed = flag != 0, + _ => (), + } + } + + if added_at.is_none() && uri.is_some() && !removed { + log::debug!("collection: saved item has no date, fields: {}", fields(bytes)); + } + + Ok(uri.filter(|_| !removed).map(|uri| SavedItem { uri, added_at })) +} + +fn fields(bytes: &[u8]) -> String { + let mut reader = Reader::new(bytes); + let mut seen = Vec::new(); + while let Ok(Some((field, value))) = reader.field() { + seen.push(match value { + Value::Varint(raw) => format!("{field}=varint({raw})"), + Value::Fixed(raw) => format!("{field}=fixed({raw})"), + Value::Bytes(raw) => format!("{field}=bytes({})", raw.len()), + }); + } + seen.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_saved_item_with_date() { + let mut writer = Writer::default(); + writer.string(ITEM_URI, "spotify:track:abc"); + writer.int32(ITEM_ADDED_AT, 1_725_552_000); + + let item = kept(&writer.finish()).unwrap().unwrap(); + + assert_eq!(item.uri, "spotify:track:abc"); + assert_eq!(item.added_at, Some(1_725_552_000)); + } + + #[test] + fn drops_removed_item() { + let mut writer = Writer::default(); + writer.string(ITEM_URI, "spotify:track:abc"); + writer.int32(ITEM_REMOVED, 1); + + assert!(kept(&writer.finish()).unwrap().is_none()); + } +} diff --git a/crates/spotify/src/credentials.rs b/crates/spotify/src/credentials.rs new file mode 100644 index 0000000..2acc1de --- /dev/null +++ b/crates/spotify/src/credentials.rs @@ -0,0 +1,52 @@ +//! Where the Spotify provider keeps its on-disk state: the librespot credential cache and the +//! Pathfinder query-hash registry. Both live directly under the provider's data directory. +//! +//! The root is set once, by [`crate::SpotifyProvider::new`], and read process-wide (the Pathfinder +//! hash cache reaches for it deep in the metadata path where threading a handle would be noise). + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// The credential file librespot writes after a successful connect. +pub(crate) const FILE: &str = "credentials.json"; +/// The stable per-installation Spotify device id (see `auth::device_id`). Lives next to the +/// credentials so one sign-out forgets both together. +pub(crate) const DEVICE_FILE: &str = "device.id"; + +static ROOT: OnceLock = OnceLock::new(); + +/// Record the provider's data directory. The first caller wins; later calls are ignored so a +/// second provider cannot silently repoint an already-open cache. +pub(crate) fn set_root(dir: PathBuf) { + let _ = ROOT.set(dir); +} + +/// The provider's data directory. Falls back to a temp dir when no provider was constructed +/// (only the offline unit tests hit that path). +pub(crate) fn root() -> PathBuf { + ROOT.get().cloned().unwrap_or_else(|| std::env::temp_dir().join("ryotunes-spotify")) +} + +/// Restricts an existing credential file to its owner. A missing file is left alone. +pub(crate) fn secure(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!("credentials: cannot restrict {}: {error}", path.display()); + } + } + #[cfg(not(unix))] + let _ = path; +} + +/// Deletes a credential file, treating one that is already gone as success. +pub(crate) fn remove(path: &Path) { + if let Err(error) = std::fs::remove_file(path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!("credentials: cannot remove {}: {error}", path.display()); + } +} diff --git a/crates/spotify/src/lib.rs b/crates/spotify/src/lib.rs new file mode 100644 index 0000000..0c52737 --- /dev/null +++ b/crates/spotify/src/lib.rs @@ -0,0 +1,61 @@ +//! Ryotunes Spotify provider. +//! +//! Lifted from nolight132/sonora (`crates/music/src/spotify`, GPL-3.0-or-later), commit 695242e, +//! and rewired for Ryotunes. See `README.md` for provenance, the `SongItem` mapping, and the +//! daemon-wiring notes. +//! +//! Boundary: this crate knows nothing about GPUI, Tauri, mpv, or UI state. Metadata comes from +//! librespot's session (Pathfinder GraphQL + protobuf extended-metadata + the collection v2 API). +//! Audio reaches Ryotunes' single libmpv engine through librespot-playback's `pipe` backend written +//! to a per-session FIFO that mpv opens as raw S16LE / 44100 Hz / stereo — never cpal or rodio. + +pub mod credentials; +pub mod models; + +mod albums; +mod artists; +mod auth; +mod client; +mod collection; +mod collection2; +mod lyrics; +mod pathfinder; +mod pb; +mod playlists; +mod profiles; +mod provider; +mod radio; +mod search; +mod stream; +mod wire; + +pub use auth::{SignInFailure, SignInProblem}; +pub use client::{Client, SearchResults}; +pub use models::*; +pub use provider::{SpotifyConfig, SpotifyProvider}; +pub use stream::{StreamControls, StreamEvent, StreamHandle}; + +/// The media kinds [`Client::share_url`] can address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MediaKind { + Track, + Album, + Artist, + Playlist, +} + +/// The first `wanted` distinct cover URLs across `tracks`, in playback order — used to build the +/// composite covers a playlist shows before its own artwork is known. +pub fn distinct_covers(tracks: &[models::Track], wanted: usize) -> Vec { + let mut covers: Vec = Vec::with_capacity(wanted); + for cover in tracks.iter().filter_map(|track| track.cover.as_deref()) { + if covers.len() == wanted { + break; + } + if !covers.iter().any(|kept| kept == cover) { + covers.push(cover.to_owned()); + } + } + + covers +} diff --git a/crates/spotify/src/lyrics.rs b/crates/spotify/src/lyrics.rs new file mode 100644 index 0000000..72976c6 --- /dev/null +++ b/crates/spotify/src/lyrics.rs @@ -0,0 +1,223 @@ +//! Spotify color-lyrics. Lifted from nolight132/sonora `spotify/lyrics.rs`. +//! +//! Change from Sonora: the synced lines are normalized locally (sort by start, close open ends +//! from the following line) instead of running Sonora's 970-line LRC pipeline — Spotify's payload +//! is already ordered, one clean line per verse with syllable timing, so the heavy pass (which also +//! pulled in kakasi romanization) buys nothing here. + +use anyhow::{Context as _, Result}; +use bytes::Bytes; +use http::{Method, Request, header}; +use librespot_core::{Session, spclient::CLIENT_TOKEN}; +use serde::Deserialize; + +use crate::{Lyrics, LyricsLine, LyricsWord, Voice}; + +const ENDPOINT: &str = "https://spclient.wg.spotify.com/color-lyrics/v2/track"; +const APP_PLATFORM: &str = "WebPlayer"; + +#[derive(Deserialize)] +struct Answer { + lyrics: Option, +} + +#[derive(Deserialize)] +struct Sheet { + #[serde(default, rename = "syncType")] + sync: String, + #[serde(default)] + lines: Vec, +} + +#[derive(Deserialize)] +struct Verse { + #[serde(default, rename = "startTimeMs")] + start: String, + #[serde(default, rename = "endTimeMs")] + end: String, + #[serde(default)] + words: String, + #[serde(default)] + syllables: Vec, +} + +#[derive(Deserialize)] +struct Syllable { + #[serde(default, rename = "startTimeMs")] + start: String, + #[serde(default, rename = "endTimeMs")] + end: String, + #[serde(default)] + text: String, +} + +pub async fn lyrics(session: &Session, track_id: &str) -> Result> { + let token = + session.login5().auth_token().await.context("cannot obtain Spotify access token")?; + let client_token = + session.spclient().client_token().await.context("cannot obtain Spotify client token")?; + let request = Request::builder() + .method(Method::GET) + .uri(format!("{ENDPOINT}/{track_id}?format=json&vocalRemoval=false&market=from_token")) + .header(header::ACCEPT, "application/json") + .header("app-platform", APP_PLATFORM) + .header(header::AUTHORIZATION, format!("{} {}", token.token_type, token.access_token)) + .header(CLIENT_TOKEN, client_token) + .body(Bytes::new()) + .context("cannot build the Spotify lyrics request")?; + let body = match session.http_client().request_body(request).await { + Ok(body) => body, + Err(error) => { + log::debug!("lyrics: spotify has none for {track_id}: {error}"); + return Ok(None); + } + }; + let answer: Answer = + serde_json::from_slice(&body).context("cannot decode the Spotify lyrics response")?; + Ok(answer.lyrics.and_then(sheet)) +} + +fn sheet(sheet: Sheet) -> Option { + if sheet.sync == "UNSYNCED" { + let text = sheet + .lines + .iter() + .map(|verse| verse.words.trim()) + .filter(|words| !words.is_empty()) + .collect::>() + .join("\n"); + return (!text.trim().is_empty()).then(|| Lyrics::plain(text)); + } + + let mut lines: Vec = sheet.lines.iter().filter_map(verse).collect(); + normalize(&mut lines); + (!lines.is_empty()).then(|| Lyrics::Synced { lines: lines.into() }) +} + +/// Order the lines and, where a verse gave no end cue, close it at the next verse's start. Spotify +/// verses are already clean and mostly carry an `endTimeMs`, so this is all the shaping they need. +fn normalize(lines: &mut [LyricsLine]) { + lines.sort_by_key(|line| line.start); + let starts: Vec<_> = lines.iter().map(|line| line.start).collect(); + for (index, line) in lines.iter_mut().enumerate() { + if line.end.is_none() + && let Some(&next) = starts.get(index + 1) + && next > line.start + { + line.end = Some(next); + } + } +} + +fn verse(verse: &Verse) -> Option { + let text = verse.words.trim(); + if text.is_empty() || text == "♪" { + return None; + } + let start = stamp(&verse.start)?; + let words: Vec = verse + .syllables + .iter() + .filter_map(|syllable| { + Some(LyricsWord { + start: stamp(&syllable.start)?, + end: stamp(&syllable.end)?, + text: syllable.text.clone(), + }) + }) + .collect(); + Some(LyricsLine { + start, + end: stamp(&verse.end).filter(|end| *end > start), + text: text.to_owned(), + romanized: None, + words: (!words.is_empty()).then_some(words), + secondary: Vec::new(), + voice: Voice::Lead, + }) +} + +fn stamp(millis: &str) -> Option { + millis.trim().parse().ok().map(std::time::Duration::from_millis) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verse_of(start: u64, end: Option) -> LyricsLine { + LyricsLine { + start: std::time::Duration::from_millis(start), + end: end.map(std::time::Duration::from_millis), + text: format!("line {start}"), + romanized: None, + words: None, + secondary: Vec::new(), + voice: Voice::Lead, + } + } + + #[test] + fn normalize_orders_and_closes_open_ends() { + let mut lines = vec![verse_of(2000, None), verse_of(0, None), verse_of(4000, None)]; + normalize(&mut lines); + + let stamps: Vec<_> = lines.iter().map(|line| line.start.as_millis()).collect(); + assert_eq!(stamps, [0, 2000, 4000]); + // Each open line is closed at the next line's start; the last one stays open. + assert_eq!(lines[0].end, Some(std::time::Duration::from_millis(2000))); + assert_eq!(lines[1].end, Some(std::time::Duration::from_millis(4000))); + assert_eq!(lines[2].end, None); + } + + #[test] + fn normalize_keeps_a_real_end_cue() { + let mut lines = vec![verse_of(0, Some(1500)), verse_of(2000, None)]; + normalize(&mut lines); + assert_eq!(lines[0].end, Some(std::time::Duration::from_millis(1500))); + } + + #[test] + fn parses_a_synced_sheet_with_syllables() { + let raw = serde_json::json!({ + "lyrics": { + "syncType": "LINE_SYNCED", + "lines": [ + { "startTimeMs": "1000", "endTimeMs": "2000", "words": "hello world", + "syllables": [ + { "startTimeMs": "1000", "endTimeMs": "1400", "text": "hello" }, + { "startTimeMs": "1400", "endTimeMs": "2000", "text": "world" } + ] }, + { "startTimeMs": "0", "endTimeMs": "1000", "words": "♪", "syllables": [] } + ] + } + }); + let answer: Answer = serde_json::from_value(raw).unwrap(); + let Some(Lyrics::Synced { lines }) = answer.lyrics.and_then(sheet) else { + panic!("expected synced lyrics"); + }; + // The "♪" filler line is dropped; the sung line survives with its two syllables. + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "hello world"); + assert!(lines[0].worded()); + } + + #[test] + fn unsynced_becomes_plain_text() { + let raw = serde_json::json!({ + "lyrics": { + "syncType": "UNSYNCED", + "lines": [ + { "words": "first" }, + { "words": "" }, + { "words": "second" } + ] + } + }); + let answer: Answer = serde_json::from_value(raw).unwrap(); + let Some(Lyrics::Plain { text, .. }) = answer.lyrics.and_then(sheet) else { + panic!("expected plain lyrics"); + }; + assert_eq!(text, "first\nsecond"); + } +} diff --git a/crates/spotify/src/models.rs b/crates/spotify/src/models.rs new file mode 100644 index 0000000..758ac3c --- /dev/null +++ b/crates/spotify/src/models.rs @@ -0,0 +1,357 @@ +//! Domain types for the Spotify provider. +//! +//! Lifted verbatim from nolight132/sonora `crates/music/src/models.rs`, trimmed to the types the +//! Spotify code actually produces. The only behavioural change: [`Lyrics::plain`] no longer +//! romanizes (Sonora pulled in kakasi/deunicode for that); the `romanized` fields remain in the +//! shapes so serialization matches, they are simply never populated here. +//! +//! See `README.md` for how these map onto `ryotunes_core::innertube` (`SongItem`-like) shapes. + +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserProfile { + pub id: String, + pub display_name: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct UserDetail { + pub id: String, + pub name: String, + pub avatar: Option, + pub followers: Option, + pub following: Option, + pub playlists: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Contributor { + pub id: String, + pub name: String, + pub avatar: Option, +} + +impl Contributor { + pub fn unnamed(id: impl Into) -> Self { + let id = id.into(); + Self { name: id.clone(), id, avatar: None } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtistRef { + pub name: String, + pub id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Credit { + pub name: String, + pub role: String, + pub id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Track { + pub id: Option, + pub name: String, + pub playable: bool, + pub artists: String, + pub artist_refs: Vec, + pub album: String, + pub album_id: Option, + pub cover: Option, + pub duration: Duration, + pub added_at: Option, + pub added_by: Option>, + pub playcount: Option, + pub popularity: u32, + pub explicit: bool, + pub track_number: u32, + pub disc_number: u32, + pub tags: Vec, + pub languages: Vec, + pub credits: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Playlist { + pub id: String, + pub name: String, + pub owner: String, + pub owner_id: String, + pub owned: bool, + pub collaborative: bool, + pub blend: bool, + pub public: bool, + pub cover: Option, + pub track_count: u32, + pub modified_at: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ReleaseType { + Album, + Single, + Compilation, + Ep, + Audiobook, + Podcast, +} + +impl ReleaseType { + pub fn label(self) -> &'static str { + match self { + Self::Album => "Album", + Self::Single => "Single", + Self::Compilation => "Compilation", + Self::Ep => "EP", + Self::Audiobook => "Audiobook", + Self::Podcast => "Podcast", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Album { + pub id: String, + pub name: String, + pub artists: String, + pub artist_refs: Vec, + pub cover: Option, + pub cover_large: Option, + pub release_type: ReleaseType, + pub year: i32, + pub track_count: u32, + pub release_date: String, + pub label: String, + pub copyrights: Vec, + pub added_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AlbumDetail { + pub album: Album, + pub tracks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Genre { + pub id: String, + pub name: String, + pub cover: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GenreItem { + Playlist(Playlist), + Album(Album), + Genre(Genre), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GenreSection { + pub title: String, + pub items: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HomeFeed { + pub listen_again: Vec, + pub quick_picks: Option>, + pub sections: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GenreDetail { + pub name: String, + pub sections: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlaylistDetail { + pub playlist: Playlist, + pub tracks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtistProfile { + pub name: String, + pub cover_large: Option, + pub biography: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SavedArtist { + pub id: String, + pub name: String, + pub cover: Option, + pub added_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Artist { + pub name: String, + pub cover_large: Option, + pub biography: Option, + pub monthly_listeners: Option, + pub top_tracks: Vec, + pub albums: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Lyrics { + Plain { + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + romanized: Option, + }, + Synced { + lines: Arc<[LyricsLine]>, + }, +} + +impl Lyrics { + /// Unsynced lyrics. Ryotunes does not romanize (Sonora did, via kakasi); `romanized` stays + /// `None` so daemon-side serialization keeps the same shape. + pub fn plain(text: impl Into) -> Self { + Self::Plain { text: text.into(), romanized: None } + } + + pub fn synced(&self) -> bool { + matches!(self, Self::Synced { .. }) + } + + pub fn worded(&self) -> bool { + match self { + Self::Plain { .. } => false, + Self::Synced { lines } => lines.iter().any(LyricsLine::worded), + } + } + + pub fn is_empty(&self) -> bool { + match self { + Self::Plain { text, .. } => text.trim().is_empty(), + Self::Synced { lines } => lines.is_empty(), + } + } + + pub fn span(&self) -> Option { + let Self::Synced { lines } = self else { + return None; + }; + lines.iter().map(|line| line.end.unwrap_or(line.start)).max() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LyricsLine { + pub start: Duration, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end: Option, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub romanized: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub words: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secondary: Vec, + #[serde(default, skip_serializing_if = "Voice::lead")] + pub voice: Voice, +} + +impl LyricsLine { + pub fn worded(&self) -> bool { + self.words.as_ref().is_some_and(|words| !words.is_empty()) + || self.secondary.iter().any(LyricsLane::worded) + } + + pub fn sung_end(&self) -> Option { + let primary = self + .words + .as_ref() + .and_then(|words| words.iter().rev().find(|word| !word.text.trim().is_empty())) + .map(|word| word.end.max(word.start).max(self.start)) + .or(self.end); + self.secondary.iter().filter_map(LyricsLane::sung_end).chain(primary).max() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LyricsLane { + pub start: Duration, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end: Option, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub romanized: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub words: Option>, +} + +impl LyricsLane { + pub fn worded(&self) -> bool { + self.words.as_ref().is_some_and(|words| !words.is_empty()) + } + + pub fn sung_end(&self) -> Option { + self.words + .as_ref() + .and_then(|words| words.iter().rev().find(|word| !word.text.trim().is_empty())) + .map(|word| word.end.max(word.start).max(self.start)) + .or(self.end) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RomanizedText { + pub text: String, + pub writing_system: WritingSystem, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WritingSystem { + Japanese, + Chinese, + Korean, + Cyrillic, + Greek, + Arabic, + Other, +} + +impl WritingSystem { + pub const ALL: [Self; 7] = [ + Self::Japanese, + Self::Chinese, + Self::Korean, + Self::Cyrillic, + Self::Greek, + Self::Arabic, + Self::Other, + ]; +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Voice { + #[default] + Lead, + Counter, +} + +impl Voice { + pub fn lead(&self) -> bool { + matches!(self, Self::Lead) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LyricsWord { + pub start: Duration, + pub end: Duration, + pub text: String, +} diff --git a/crates/spotify/src/pathfinder.rs b/crates/spotify/src/pathfinder.rs new file mode 100644 index 0000000..bdf68c7 --- /dev/null +++ b/crates/spotify/src/pathfinder.rs @@ -0,0 +1,132 @@ +use anyhow::{Context as _, Result, bail}; +use bytes::Bytes; +use http::{Method, Request, header}; +use librespot_core::{Session, spclient::CLIENT_TOKEN}; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +mod album; +mod artist; +mod browse; +mod hashes; +mod plays; +mod search; + +pub(crate) use album::album; +pub(crate) use artist::{Overview, artist}; +pub(crate) use browse::{all as genres, page as genre}; +pub(crate) use plays::track; +pub(crate) use search::{albums as search_albums, playlists as search_playlists}; + +const ENDPOINT: &str = "https://api-partner.spotify.com/pathfinder/v2/query"; +const APP_PLATFORM: &str = "WebPlayer"; +const APP_VERSION: &str = "896000000"; + +#[derive(Deserialize)] +struct Response { + data: Option, + #[serde(default)] + errors: Vec, +} + +#[derive(Deserialize)] +struct GraphqlError { + message: String, +} + +async fn query( + session: &Session, + operation: &str, + variables: serde_json::Value, +) -> Result { + let hash = hashes::resolve(session, operation).await?; + let rejected = match send(session, operation, &hash.value, &variables).await { + Ok(data) => return Ok(data), + Err(error) => error, + }; + if hash.tried { + return Err(rejected); + } + let Some(latest) = hashes::refetch(session, operation, &hash.value).await else { + return Err(rejected); + }; + if latest == hash.value { + return Err(rejected); + } + send(session, operation, &latest, &variables).await +} + +async fn send( + session: &Session, + operation: &str, + hash: &str, + variables: &serde_json::Value, +) -> Result { + let body = serde_json::to_vec(&serde_json::json!({ + "operationName": operation, + "variables": variables, + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": hash, + } + }, + })) + .with_context(|| format!("cannot encode {operation} Pathfinder request"))?; + let token = + session.login5().auth_token().await.context("cannot obtain Spotify access token")?; + let client_token = + session.spclient().client_token().await.context("cannot obtain Spotify client token")?; + let request = Request::builder() + .method(Method::POST) + .uri(ENDPOINT) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .header("app-platform", APP_PLATFORM) + .header("spotify-app-version", APP_VERSION) + .header(header::AUTHORIZATION, format!("{} {}", token.token_type, token.access_token)) + .header(CLIENT_TOKEN, client_token) + .body(Bytes::from(body)) + .with_context(|| format!("cannot build {operation} Pathfinder request"))?; + let body = session + .http_client() + .request_body(request) + .await + .with_context(|| format!("cannot request {operation} from Pathfinder"))?; + decoded(&body, operation) +} + +fn decoded(bytes: &[u8], operation: &str) -> Result { + let response: Response = serde_json::from_slice(bytes) + .with_context(|| format!("cannot decode {operation} Pathfinder response"))?; + if !response.errors.is_empty() { + let messages = + response.errors.into_iter().map(|error| error.message).collect::>().join("; "); + bail!("Spotify rejected {operation} Pathfinder query: {messages}"); + } + response.data.with_context(|| format!("{operation} Pathfinder response has no data")) +} + +fn reported(count: u64) -> Option { + (count > 0).then_some(count) +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::decoded; + + #[test] + fn decodes_data() { + let data: Value = decoded(br#"{"data":{"value":42}}"#, "test").unwrap(); + assert_eq!(data["value"], 42); + } + + #[test] + fn reports_graphql_error() { + let error = decoded::(br#"{"data":null,"errors":[{"message":"bad hash"}]}"#, "test") + .unwrap_err(); + assert!(error.to_string().contains("bad hash")); + } +} diff --git a/crates/spotify/src/pathfinder/album.rs b/crates/spotify/src/pathfinder/album.rs new file mode 100644 index 0000000..da0ef13 --- /dev/null +++ b/crates/spotify/src/pathfinder/album.rs @@ -0,0 +1,368 @@ +use std::time::Duration; + +use anyhow::{Context as _, Result, anyhow}; +use librespot_core::Session; +use serde::Deserialize; + +use super::query; +use crate::{Album, AlbumDetail, ArtistRef, ReleaseType, Track}; + +const PAGE_LIMIT: usize = 50; +const ALBUM_PREFIX: &str = "spotify:album:"; +const ARTIST_PREFIX: &str = "spotify:artist:"; +const TRACK_PREFIX: &str = "spotify:track:"; +const UNKNOWN: &str = "Unknown"; + +#[derive(Deserialize)] +struct Data { + #[serde(rename = "albumUnion")] + album: Option, +} + +#[derive(Deserialize)] +struct PathAlbum { + uri: String, + name: String, + #[serde(default)] + label: String, + #[serde(rename = "type")] + kind: String, + date: Option, + #[serde(default)] + artists: Artists, + #[serde(rename = "coverArt", default)] + cover: Cover, + #[serde(default)] + copyright: Copyrights, + #[serde(rename = "tracksV2")] + tracks: AlbumTracks, +} + +#[derive(Deserialize)] +struct PathDate { + #[serde(rename = "isoString")] + iso: String, + precision: String, +} + +#[derive(Default, Deserialize)] +struct Artists { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct PathArtist { + uri: String, + profile: Profile, +} + +#[derive(Deserialize)] +struct Profile { + name: String, +} + +#[derive(Default, Deserialize)] +struct Cover { + #[serde(default)] + sources: Vec, +} + +#[derive(Deserialize)] +struct Image { + height: u32, + url: String, +} + +#[derive(Default, Deserialize)] +struct Copyrights { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct Copyright { + text: String, +} + +#[derive(Deserialize)] +struct AlbumTracks { + #[serde(default)] + items: Vec, + #[serde(rename = "totalCount")] + total_count: usize, +} + +#[derive(Deserialize)] +struct AlbumItem { + track: PathTrack, +} + +#[derive(Deserialize)] +struct PathTrack { + uri: String, + name: String, + #[serde(default)] + artists: Artists, + #[serde(default)] + playability: Playability, + #[serde(rename = "contentRating", default)] + content_rating: ContentRating, + #[serde(rename = "discNumber", default)] + disc_number: u32, + #[serde(rename = "trackNumber", default)] + track_number: u32, + #[serde(default)] + duration: PathDuration, + playcount: Option, +} + +#[derive(Default, Deserialize)] +struct Playability { + #[serde(default)] + playable: bool, +} + +#[derive(Default, Deserialize)] +struct ContentRating { + #[serde(default)] + label: String, +} + +#[derive(Default, Deserialize)] +struct PathDuration { + #[serde(rename = "totalMilliseconds", default)] + milliseconds: u64, +} + +struct Page { + album: Album, + tracks: Vec, + items: usize, + total: usize, +} + +pub(crate) async fn album(session: &Session, album_id: &str) -> Result { + let mut album = None; + let mut tracks = Vec::new(); + let mut offset = 0; + + loop { + let variables = serde_json::json!({ + "uri": format!("spotify:album:{album_id}"), + "locale": "", + "offset": offset, + "limit": PAGE_LIMIT, + }); + let data = query::(session, "getAlbum", variables).await?; + let page = page(data)?; + album.get_or_insert(page.album); + tracks.extend(page.tracks); + + let Some(next) = next_offset(offset, page.items, page.total) else { + return Ok(AlbumDetail { + album: album.context("album Pathfinder response has no album")?, + tracks, + }); + }; + offset = next; + } +} + +fn page(data: Data) -> Result { + let Some(album) = data.album else { + return Err(anyhow!("album Pathfinder response has no album")); + }; + let items = album.tracks.items.len(); + let total = album.tracks.total_count; + let header = album_from(&album); + let tracks = album + .tracks + .items + .into_iter() + .map(|item| track_from(item.track, &header)) + .collect::>()?; + Ok(Page { album: header, tracks, items, total }) +} + +fn album_from(album: &PathAlbum) -> Album { + let (artists, artist_refs) = artists(&album.artists); + let release_date = album.date.as_ref().map(date).unwrap_or_default(); + let year = release_date.get(..4).and_then(|year| year.parse().ok()).unwrap_or_default(); + + Album { + id: album.uri.strip_prefix(ALBUM_PREFIX).unwrap_or(&album.uri).to_owned(), + name: non_empty(&album.name).unwrap_or(UNKNOWN).to_owned(), + artists, + artist_refs, + cover: cover(&album.cover.sources, false), + cover_large: cover(&album.cover.sources, true), + release_type: release_type(&album.kind), + year, + track_count: album.tracks.total_count as u32, + release_date, + label: album.label.clone(), + copyrights: album + .copyright + .items + .iter() + .filter_map(|copyright| non_empty(©right.text).map(str::to_owned)) + .collect(), + added_at: None, + } +} + +fn track_from(track: PathTrack, album: &Album) -> Result { + let (artists, artist_refs) = artists(&track.artists); + let playcount = track + .playcount + .map(|count| { + count.parse().with_context(|| { + let id = track.uri.strip_prefix(TRACK_PREFIX).unwrap_or(&track.uri); + format!("invalid album play count for track {id}") + }) + }) + .transpose()? + .and_then(super::reported); + + Ok(Track { + id: track.uri.strip_prefix(TRACK_PREFIX).map(str::to_owned), + name: non_empty(&track.name).unwrap_or(UNKNOWN).to_owned(), + playable: track.playability.playable, + artists, + artist_refs, + album: album.name.clone(), + album_id: Some(album.id.clone()), + cover: album.cover.clone(), + duration: Duration::from_millis(track.duration.milliseconds), + added_at: None, + added_by: None, + playcount, + popularity: 0, + explicit: track.content_rating.label == "EXPLICIT", + track_number: track.track_number, + disc_number: track.disc_number, + tags: Vec::new(), + languages: Vec::new(), + credits: Vec::new(), + }) +} + +fn artists(artists: &Artists) -> (String, Vec) { + let refs: Vec<_> = artists + .items + .iter() + .filter_map(|artist| { + let name = non_empty(&artist.profile.name)?.to_owned(); + Some(ArtistRef { name, id: artist.uri.strip_prefix(ARTIST_PREFIX).map(str::to_owned) }) + }) + .collect(); + let names = refs.iter().map(|artist| artist.name.as_str()).collect::>().join(", "); + (names, refs) +} + +fn cover(sources: &[Image], large: bool) -> Option { + const HEADER: u32 = 300; + + let source = match large { + true => sources + .iter() + .filter(|source| source.height >= HEADER) + .min_by_key(|source| source.height) + .or_else(|| sources.iter().max_by_key(|source| source.height)), + false => sources.iter().min_by_key(|source| source.height), + }?; + non_empty(&source.url).map(str::to_owned) +} + +fn date(date: &PathDate) -> String { + let length = match date.precision.as_str() { + "YEAR" => 4, + "MONTH" => 7, + _ => 10, + }; + date.iso.get(..length).unwrap_or(&date.iso).to_owned() +} + +pub(super) fn release_type(kind: &str) -> ReleaseType { + match kind { + "SINGLE" => ReleaseType::Single, + "COMPILATION" => ReleaseType::Compilation, + "EP" => ReleaseType::Ep, + "AUDIOBOOK" => ReleaseType::Audiobook, + "PODCAST" => ReleaseType::Podcast, + _ => ReleaseType::Album, + } +} + +fn next_offset(offset: usize, items: usize, total: usize) -> Option { + let loaded = offset.saturating_add(items); + (items > 0 && loaded < total).then_some(loaded) +} + +fn non_empty(value: &str) -> Option<&str> { + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_album_page() { + let body = br#"{"albumUnion":{"uri":"spotify:album:album1","name":"The Poison","label":"Label","type":"ALBUM","date":{"isoString":"2005-01-01T00:00:00Z","precision":"YEAR"},"artists":{"items":[{"profile":{"name":"Artist"},"uri":"spotify:artist:artist1"}]},"coverArt":{"sources":[{"height":64,"url":"small"},{"height":640,"url":"large"}]},"copyright":{"items":[{"text":"Copyright"}]},"tracksV2":{"items":[{"track":{"uri":"spotify:track:abc123","name":"Song","artists":{"items":[{"profile":{"name":"Artist"},"uri":"spotify:artist:artist1"}]},"playability":{"playable":true},"contentRating":{"label":"EXPLICIT"},"discNumber":1,"trackNumber":2,"duration":{"totalMilliseconds":141453},"playcount":"462537503"}}],"totalCount":2}}}"#; + let data: Data = serde_json::from_slice(body).unwrap(); + let page = page(data).unwrap(); + assert_eq!(page.album.id, "album1"); + assert_eq!(page.album.release_date, "2005"); + assert_eq!(page.album.cover.as_deref(), Some("small")); + assert_eq!(page.album.cover_large.as_deref(), Some("large")); + assert_eq!(page.items, 1); + assert_eq!(page.total, 2); + assert_eq!(page.tracks[0].id.as_deref(), Some("abc123")); + assert_eq!(page.tracks[0].playcount, Some(462_537_503)); + assert!(page.tracks[0].playable); + assert!(page.tracks[0].explicit); + } + + #[test] + fn maps_track_uri_to_playcount() { + let track = PathTrack { + uri: "spotify:track:base62id".to_owned(), + name: "Song".to_owned(), + artists: Artists::default(), + playability: Playability::default(), + content_rating: ContentRating::default(), + disc_number: 1, + track_number: 1, + duration: PathDuration::default(), + playcount: Some("1234".to_owned()), + }; + let album = Album { + id: "album".to_owned(), + name: "Album".to_owned(), + artists: String::new(), + artist_refs: Vec::new(), + cover: None, + cover_large: None, + release_type: ReleaseType::Album, + year: 0, + track_count: 1, + release_date: String::new(), + label: String::new(), + copyrights: Vec::new(), + added_at: None, + }; + let track = track_from(track, &album).unwrap(); + assert_eq!(track.id.as_deref(), Some("base62id")); + assert_eq!(track.playcount, Some(1_234)); + } + + #[test] + fn advances_page_offset() { + assert_eq!(next_offset(0, 50, 101), Some(50)); + assert_eq!(next_offset(50, 50, 101), Some(100)); + assert_eq!(next_offset(100, 1, 101), None); + assert_eq!(next_offset(50, 0, 101), None); + } +} diff --git a/crates/spotify/src/pathfinder/artist.rs b/crates/spotify/src/pathfinder/artist.rs new file mode 100644 index 0000000..90fb9f6 --- /dev/null +++ b/crates/spotify/src/pathfinder/artist.rs @@ -0,0 +1,331 @@ +use std::collections::HashSet; + +use anyhow::{Context as _, Result}; +use librespot_core::Session; +use serde::Deserialize; + +use super::query; +use crate::{Album, ArtistRef, ReleaseType}; + +pub(crate) struct Overview { + pub(crate) name: String, + pub(crate) cover_large: Option, + pub(crate) biography: Option, + pub(crate) monthly_listeners: Option, + pub(crate) tracks: Vec<(String, Option)>, + pub(crate) albums: Vec, +} + +#[derive(Deserialize)] +struct Data { + #[serde(rename = "artistUnion")] + artist: Option, +} + +#[derive(Deserialize)] +struct PathArtist { + profile: Profile, + #[serde(default)] + visuals: Visuals, + discography: Option, + stats: Option, +} + +#[derive(Deserialize)] +struct Profile { + name: String, + biography: Option, +} + +#[derive(Deserialize)] +struct Biography { + text: Option, +} + +#[derive(Default, Deserialize)] +struct Visuals { + #[serde(rename = "avatarImage", default)] + avatar: Cover, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Discography { + top_tracks: Option, + #[serde(default)] + albums: ReleaseGroups, + #[serde(default)] + singles: ReleaseGroups, + #[serde(default)] + compilations: ReleaseGroups, +} + +#[derive(Deserialize)] +struct Tracks { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct Item { + track: PathTrack, +} + +#[derive(Deserialize)] +struct PathTrack { + uri: String, + playcount: Option, +} + +#[derive(Default, Deserialize)] +struct ReleaseGroups { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct ReleaseGroup { + releases: Releases, +} + +#[derive(Deserialize)] +struct Releases { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct Release { + id: String, + name: String, + #[serde(rename = "type")] + kind: String, + date: Option, + #[serde(rename = "coverArt", default)] + cover: Cover, + #[serde(default)] + label: String, + #[serde(default)] + copyright: Copyrights, + #[serde(default)] + tracks: ReleaseTracks, +} + +#[derive(Deserialize)] +struct PathDate { + year: i32, + month: Option, + day: Option, +} + +#[derive(Default, Deserialize)] +struct Cover { + #[serde(default)] + sources: Vec, +} + +#[derive(Deserialize)] +struct Image { + height: u32, + url: String, +} + +#[derive(Default, Deserialize)] +struct Copyrights { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct Copyright { + text: String, +} + +#[derive(Default, Deserialize)] +struct ReleaseTracks { + #[serde(rename = "totalCount", default)] + total_count: u32, +} + +#[derive(Deserialize)] +struct Stats { + #[serde(rename = "monthlyListeners")] + monthly_listeners: Option, +} + +pub(crate) async fn artist(session: &Session, artist_id: &str) -> Result { + let variables = variables(artist_id); + let data = query::(session, "queryArtistOverview", variables).await?; + overview(data, artist_id) +} + +fn variables(artist_id: &str) -> serde_json::Value { + serde_json::json!({ + "uri": format!("spotify:artist:{artist_id}"), + "locale": "", + "preReleaseV2": true, + }) +} + +fn overview(data: Data, artist_id: &str) -> Result { + let artist = data.artist.context("artist Pathfinder response has no artist")?; + let discography = + artist.discography.context("artist Pathfinder response has no discography")?; + let Discography { top_tracks, albums, singles, compilations } = discography; + let tracks = top_tracks + .into_iter() + .flat_map(|tracks| tracks.items) + .map(|item| { + let count = + item.track.playcount.and_then(|count| count.parse().ok()).and_then(super::reported); + (item.track.uri, count) + }) + .collect(); + let artist_ref = + ArtistRef { name: artist.profile.name.clone(), id: Some(artist_id.to_owned()) }; + let albums = releases(albums, singles, compilations, &artist_ref); + + Ok(Overview { + name: artist.profile.name, + cover_large: cover(&artist.visuals.avatar.sources, true), + biography: artist.profile.biography.and_then(|biography| biography.text), + monthly_listeners: artist.stats.and_then(|stats| stats.monthly_listeners), + tracks, + albums, + }) +} + +fn releases( + albums: ReleaseGroups, + singles: ReleaseGroups, + compilations: ReleaseGroups, + artist: &ArtistRef, +) -> Vec { + let mut seen = HashSet::new(); + + albums + .items + .into_iter() + .chain(singles.items) + .chain(compilations.items) + .flat_map(|group| group.releases.items) + .filter(|release| seen.insert(release.id.clone())) + .map(|release| album(release, artist)) + .collect() +} + +fn album(release: Release, artist: &ArtistRef) -> Album { + let release_date = release.date.as_ref().map(date).unwrap_or_default(); + let year = release.date.as_ref().map(|date| date.year).unwrap_or_default(); + + Album { + id: release.id, + name: release.name, + artists: artist.name.clone(), + artist_refs: vec![artist.clone()], + cover: cover(&release.cover.sources, false), + cover_large: cover(&release.cover.sources, true), + release_type: release_type(&release.kind), + year, + track_count: release.tracks.total_count, + release_date, + label: release.label, + copyrights: release.copyright.items.into_iter().map(|copyright| copyright.text).collect(), + added_at: None, + } +} + +fn date(date: &PathDate) -> String { + match (date.month, date.day) { + (Some(month), Some(day)) => format!("{:04}-{month:02}-{day:02}", date.year), + (Some(month), None) => format!("{:04}-{month:02}", date.year), + _ => format!("{:04}", date.year), + } +} + +fn cover(sources: &[Image], large: bool) -> Option { + let picked = match large { + true => sources + .iter() + .filter(|image| image.height >= 300) + .min_by_key(|image| image.height) + .or_else(|| sources.iter().max_by_key(|image| image.height)), + false => sources.iter().min_by_key(|image| image.height), + }?; + + Some(picked.url.clone()) +} + +fn release_type(kind: &str) -> ReleaseType { + match kind { + "ALBUM" => ReleaseType::Album, + "COMPILATION" => ReleaseType::Compilation, + "EP" => ReleaseType::Ep, + "AUDIOBOOK" => ReleaseType::Audiobook, + "PODCAST" => ReleaseType::Podcast, + _ => ReleaseType::Single, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_overview() { + let data: Data = serde_json::from_slice( + br#"{"artistUnion":{"profile":{"name":"Artist","biography":{"text":"About"}},"visuals":{"avatarImage":{"sources":[{"height":640,"url":"large"},{"height":160,"url":"small"},{"height":320,"url":"header"}]}},"discography":{"topTracks":{"items":[{"track":{"uri":"spotify:track:abc","playcount":"57545277"}},{"track":{"uri":"spotify:track:def","playcount":null}}]},"albums":{"items":[{"releases":{"items":[{"id":"album","name":"Release","type":"ALBUM","date":{"day":2,"month":3,"year":2024},"coverArt":{"sources":[{"height":300,"url":"album-large"},{"height":64,"url":"album-small"}]},"label":"Label","copyright":{"items":[{"text":"Copyright"}]},"tracks":{"totalCount":12}}]}}]},"singles":{"items":[]},"compilations":{"items":[]}},"stats":{"monthlyListeners":1900430}}}"#, + ) + .unwrap(); + let overview = overview(data, "artist").unwrap(); + + assert_eq!(overview.name, "Artist"); + assert_eq!(overview.cover_large.as_deref(), Some("header")); + assert_eq!(overview.biography.as_deref(), Some("About")); + assert_eq!(overview.monthly_listeners, Some(1_900_430)); + assert_eq!( + overview.tracks, + vec![ + ("spotify:track:abc".to_owned(), Some(57_545_277)), + ("spotify:track:def".to_owned(), None), + ] + ); + assert_eq!(overview.albums.len(), 1); + assert_eq!(overview.albums[0].name, "Release"); + assert_eq!(overview.albums[0].cover.as_deref(), Some("album-small")); + assert_eq!(overview.albums[0].cover_large.as_deref(), Some("album-large")); + assert_eq!(overview.albums[0].release_date, "2024-03-02"); + assert_eq!(overview.albums[0].track_count, 12); + assert_eq!(overview.albums[0].artist_refs[0].id.as_deref(), Some("artist")); + } + + #[test] + fn accepts_null_biography() { + let data: Data = serde_json::from_slice( + br#"{"artistUnion":{"profile":{"name":"Artist","biography":{"text":null}},"discography":{"topTracks":{"items":[{"track":{"uri":"spotify:track:abc","playcount":"42"}},{"track":{"uri":"spotify:track:def","playcount":"0"}}]}}}}"#, + ) + .unwrap(); + let overview = overview(data, "artist").unwrap(); + + assert_eq!(overview.biography, None); + assert_eq!( + overview.tracks, + vec![ + ("spotify:track:abc".to_owned(), Some(42)), + ("spotify:track:def".to_owned(), None), + ] + ); + } + + #[test] + fn sends_current_overview_variables() { + assert_eq!( + variables("artist1"), + serde_json::json!({ + "uri": "spotify:artist:artist1", + "locale": "", + "preReleaseV2": true, + }) + ); + } +} diff --git a/crates/spotify/src/pathfinder/browse.rs b/crates/spotify/src/pathfinder/browse.rs new file mode 100644 index 0000000..3b52cad --- /dev/null +++ b/crates/spotify/src/pathfinder/browse.rs @@ -0,0 +1,319 @@ +use anyhow::{Context as _, Result}; +use librespot_core::Session; +use serde::{Deserialize, Deserializer}; + +use super::query; +use crate::{Album, ArtistRef, Genre, GenreDetail, GenreItem, GenreSection, Playlist, ReleaseType}; + +const PAGE_PREFIX: &str = "spotify:page:"; +const PLAYLIST_PREFIX: &str = "spotify:playlist:"; +const ALBUM_PREFIX: &str = "spotify:album:"; +const ARTIST_PREFIX: &str = "spotify:artist:"; +const SHOW_ART: &str = "ab676563"; +const INTEGRATION: &str = "INTEGRATION_WEB_PLAYER"; +const SECTIONS: u32 = 20; +const ITEMS: u32 = 10; +const CARDS: u32 = 99; + +#[derive(Deserialize)] +struct Start { + #[serde(rename = "browseStart")] + start: Option, +} + +#[derive(Deserialize)] +struct Page { + browse: Option, +} + +#[derive(Deserialize)] +struct Container { + header: Option
, + #[serde(default, deserialize_with = "nullable")] + sections: Sections, +} + +#[derive(Deserialize)] +struct Header { + title: Option
### Quiet in the background -During background playback, the expensive visible WebKit surface can be **destroyed / hibernated** while the native playback session continues. +The QML window can close while the daemon continues playback or active downloads.