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..1776849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,150 @@ # Changelog +## Unreleased + +- Lyrics can now be romanized from Settings ▸ Appearance, including pinyin readings for Chinese and transliterations for Japanese and Korean. +- TIDAL sign-in now opens its approval page in the browser while keeping the code visible (`client/lib/playback.js`, `client/tests/tst_playback.qml`). +- Playlist and album Play actions now start in sequence, while clicking a track preserves the current shuffle mode. Active shuffle and repeat controls follow the live Ryoku accent. +- Icon buttons now show their labels on hover and expose the same labels to assistive technology. +- Tracks and browse cards now open their actions on right-click. Context menus stay inside the window and close on outside clicks or scrolling. +- Settings ▸ Downloads can now save tracks as FLAC, for players and libraries that expect FLAC files. It adds no further lossy compression but cannot make a YouTube or SoundCloud source better than it is, and files are several times larger. Cover art and lyrics are embedded as with the other formats. Tracks already saved in another format are not downloaded again. Saved formats from newer Ryotunes versions fall back to Original instead of preventing the daemon from starting. + +## v1.2.3 - 2026-10-19 + +- Void Linux support: `scripts/install-void.sh` and Settings → About → Update now install the release as an xbps package. See docs/INSTALL-VOID.md. +- Fixed the daemon not starting on glibc older than 2.44 (Void ships 2.41). Release builds now fail if any binary needs a newer glibc than 2.41. +- Fixed missing cover art on Void: the package now pulls in Qt's TLS plugin, and `ryotunes-diagnostics` reports when it's missing. + +## v1.2.2 - 2026-10-19 + +- Settings ▸ Software updates now updates Ryotunes on Ryoku's Void and Fedora editions too. Where Ryotunes came from the Ryoku package repository, Update asks polkit to update it with xbps or dnf instead of refusing because pacman is missing, and says so when the repository has not built the new release yet. + +## v1.2.1 - 2026-10-17 + +## v1.2.0 - 2026-10-14 + +- Fixed Settings ▸ Software updates wrongly saying "This is not a pacman-managed Ryotunes installation" after a system upgrade replaced Ryotunes while it was running. It now says the new version is already installed and offers "Restart to finish update", which restarts straight into it with nothing to download. + +## v1.1.9 - 2026-10-14 + +- Every song, album, playlist and artist now shows where it comes from: cards carry a small YouTube Music, Spotify or SoundCloud badge in that service's colour on the cover, and track rows show the same glyph beside the title. Ryotunes' own collections (On Repeat, local playlists, files on disk, radio) carry none. + +## v1.1.8 - 2026-10-14 + +- Fixed Spotify playback stopping at the end of every track instead of moving on, and seeking doing nothing: the track now ends cleanly into the next one, and seeking jumps within the already-buffered track. +- Spotify tracks now show their length in the desktop media widget. +- Saved Spotify songs, albums and artists now come back newest first, and Spotify playlists and Liked Songs have the same sort menu as the other providers. +- A Spotify session that drops mid-listen now reconnects on its own from the saved sign-in, with a short cooldown between attempts, instead of giving up after the first reconnect. An account Spotify refuses outright (no Premium, out of region, revoked) is not retried until you sign in again. +- YouTube Music now answers in your system language and region (from `LC_ALL`, `LC_MESSAGES` or `LANG`) instead of always US English. The Tauri build's own menus and dialogs also gain a French translation. + +## v1.1.7 - 2026-10-14 + +- Fixed music stopping and the app closing after a few minutes of playback: the daemon's hidden YouTube helper views loaded the desktop accessibility bridge, and when the session's accessibility bus went stale the bridge crashed the whole daemon the next time a helper view was built. The daemon no longer loads the bridge at all, since those views are never shown. + +## 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..29ffd89 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" @@ -1314,6 +1482,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -1400,6 +1578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1476,6 +1655,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 +1921,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 +1992,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 +2026,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 +2052,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 +2106,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 +2179,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 +2195,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1923,6 +2206,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 +2255,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 +2281,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.5", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2140,7 +2466,7 @@ dependencies = [ [[package]] name = "innertube" -version = "2.4.1" +version = "1.2.3" dependencies = [ "hex", "reqwest 0.12.28", @@ -2154,6 +2480,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 +2516,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 +2723,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 +2776,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,13 +2807,148 @@ 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", + "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", ] @@ -2473,7 +2972,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "listen-protocol" -version = "2.4.1" +version = "1.2.3" dependencies = [ "serde", "serde_json", @@ -2695,6 +3194,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 +3218,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 +3287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2750,6 +3321,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 +3478,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 +3571,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 +3675,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" @@ -3135,6 +3777,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pinyin" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" + [[package]] name = "piper" version = "0.2.5" @@ -3146,6 +3794,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 +3823,12 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "player" -version = "2.4.1" +version = "1.2.3" dependencies = [ "libc", "libmpv2", + "parking_lot", + "serde", "thiserror 2.0.20", "tokio", "tracing", @@ -3171,7 +3842,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -3283,6 +3954,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 +4027,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 +4120,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.43", "socket2 0.6.5", "thiserror 2.0.20", "tokio", @@ -3387,7 +4141,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls", + "rustls 0.23.43", "rustls-pki-types", "slab", "thiserror 2.0.20", @@ -3507,6 +4261,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 +4374,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 +4518,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 +4607,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 +4630,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 +4679,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" @@ -3891,42 +4736,194 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "ruzstd" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryotunes" -version = "2.4.1" +version = "1.2.3" +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.2.3" +dependencies = [ + "ryotunes-protocol", + "serde", + "serde_json", +] + +[[package]] +name = "ryotunes-core" +version = "1.2.3" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "discord-rich-presence", "futures-util", "innertube", - "ksni", "libc", "listen-protocol", "lofty", "md-5", + "parking_lot", + "pinyin", "player", "rand 0.8.7", "regex", "reqwest 0.12.28", "rusqlite", - "rustls", + "rustls 0.23.43", + "ruzstd", + "ryotunes-soundcloud", + "ryotunes-spotify", + "ryotunes-tidal", "serde", "serde_json", + "sha2", "souvlaki", - "tauri", - "tauri-build", - "tauri-plugin-autostart", - "tauri-plugin-dialog", - "tauri-plugin-single-instance", + "tar", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "ryotunes-protocol" +version = "1.2.3" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "ryotunes-soundcloud" +version = "1.2.3" +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.2.3" +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 = "ryotunes-tidal" +version = "1.2.3" +dependencies = [ + "base64 0.22.1", + "parking_lot", + "rand 0.8.7", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "ryotunesd" +version = "1.2.3" +dependencies = [ + "aes", + "anyhow", + "async-trait", + "base64 0.22.1", + "cbc", + "glib", + "gtk", + "innertube", + "javascriptcore-rs", + "ksni", + "libc", + "libm", + "lofty", + "parking_lot", + "pbkdf2", + "player", + "rusqlite", + "ryotunes-core", + "ryotunes-protocol", + "ryotunes-soundcloud", + "ryotunes-tidal", + "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 +4941,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 +5007,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 +5138,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 +5226,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 +5285,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 +5303,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 +5325,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 +5446,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 +5530,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 +5662,14 @@ dependencies = [ [[package]] name = "sync-server" -version = "2.4.1" +version = "1.2.3" dependencies = [ "futures-util", "listen-protocol", "rand 0.8.7", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", "tracing-subscriber", ] @@ -4494,6 +5694,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" @@ -4558,6 +5793,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5023,13 +6268,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 +6308,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 +6341,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5335,13 +6619,38 @@ 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 = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "typeid" version = "1.0.3" @@ -5506,6 +6815,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 +7082,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 +7146,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 +7336,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..1ce05c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,18 @@ members = [ "crates/player", "crates/listen-protocol", "crates/sync-server", + "crates/core", + "crates/spotify", + "crates/soundcloud", + "crates/tidal", + "crates/protocol", + "crates/ryotunesd", + "crates/ryotunes-cli", "src-tauri", ] [workspace.package] -version = "2.4.1" +version = "1.2.3" edition = "2021" license = "GPL-3.0-or-later" @@ -23,6 +30,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 +42,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..61ff5b8 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 / FLAC 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**, and Void Linux (see **[docs/INSTALL-VOID.md](docs/INSTALL-VOID.md)**). -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..8269ea6 --- /dev/null +++ b/client/App.qml @@ -0,0 +1,604 @@ +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(() => {}); + } + } + } + + Hairline { Layout.fillWidth: true } + + // ── TIDAL ──────────────────────────────────────────────────────────────────── + // Signed out this is one line, the action alone. The account line appears once there + // is an account to name, or while a device code is live and has to stay readable. + RowLayout { + readonly property var td: Playback.tidal || ({}) + visible: !!td.signedIn || !!td.code + Layout.fillWidth: true + Layout.margins: Style.sp(1) + spacing: Style.sp(2) + Icon { + name: "tidal" + size: Style.fs.lg + color: (Playback.tidal && Playback.tidal.signedIn) + ? Style.providerColors.tidal : Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + text: (Playback.tidal && Playback.tidal.signedIn && Playback.tidal.name) + ? Playback.tidal.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 { + // While a device code is live this is the only place the code is visible + // from, so it takes priority over the quality line. + readonly property var td: Playback.tidal || ({}) + text: td.code ? ("Enter " + td.code + " at " + (td.url || "link.tidal.com")) + : td.signedIn ? ("TIDAL \u00b7 " + (td.quality || "LOSSLESS")) + : "Subscription required" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + Rectangle { + id: tdAction + Layout.fillWidth: true + implicitHeight: Style.sp(9) + radius: Style.radius + color: tdHover.hovered ? Tokens.tint5 : "transparent" + readonly property bool signedIn: !!(Playback.tidal && Playback.tidal.signedIn) + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(2) + anchors.rightMargin: Style.sp(2) + spacing: Style.sp(2) + Icon { + name: tdAction.signedIn ? "close" : "tidal" + size: Style.fs.md + color: tdAction.signedIn ? Style.alert : Tokens.ink + } + Text { + Layout.fillWidth: true + text: tdAction.signedIn ? "Sign out" : "Sign in to TIDAL" + color: tdAction.signedIn ? Style.alert : Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.md + } + } + HoverHandler { id: tdHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + // The code has to stay readable, so a sign-in leaves the menu open. + if (tdAction.signedIn) { + accountMenu.visible = false; + Playback.tidalSignOut().catch(() => {}); + } else { + Playback.tidalSignIn().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..d30ccd6 --- /dev/null +++ b/client/Playback.qml @@ -0,0 +1,182 @@ +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" | "tidal"), 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: "" }) + + // The TIDAL account. Unlike SoundCloud there is no guest catalogue, so a signed-out TIDAL + // shows a sign-in prompt rather than a browsable home. `quality` is the best tier the session + // can actually reach, which the device flow caps below hi-res; `hasCredentials` stays true + // while the daemon ships a pair and only turns false if a host clears one. + property var tidal: ({ signedIn: false, name: null, quality: null, hasCredentials: false, + code: "", url: "", 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); + // Sign-in events can ask the browser to open their approval 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.tidal) + root.tidal = { signedIn: !!snap.tidal.signedIn, name: snap.tidal.name, + quality: snap.tidal.quality, hasCredentials: !!snap.tidal.hasCredentials, + code: "", url: "", error: "" }; + 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 tidalSignIn() { return Daemon.call("tidal_sign_in"); } + function tidalSignOut() { return Daemon.call("tidal_sign_out"); } + function tidalSetCredentials(c) { return Daemon.call("tidal_set_credentials", c); } + + 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..440061c --- /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", tidal: "#4fc3dc" }) + 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..6c44617 --- /dev/null +++ b/client/chrome/LyricsPanel.qml @@ -0,0 +1,673 @@ +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: "" + // A `lyrics-changed` event arrived while hidden: refetch the same track when shown again. + property bool stale: false + + // 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) + // Any line carries a Latin reading: lines then read as pairs, so they get more air between them. + readonly property bool hasReadings: { + if (!root.lyrics || !root.lyrics.lines) + return false; + for (var i = 0; i < root.lyrics.lines.length; i++) { + if (root.lyrics.lines[i].romanized) + return true; + } + return false; + } + 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) { + if (root.stale) + root.refreshInPlace(); + return; + } + root.stale = false; + 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; + if (root.stale) + root.refreshInPlace(); + }).catch(() => { + if (root.requestedId !== id) + return; + root.lyrics = null; + root.loading = false; + }); + } + + // Same track, new reading (the romanize setting flipped): swap the document without the + // loading state, the offset reset or the scroll jump a track change brings. + function refreshInPlace() { + var now = Playback.now; + if (!now || now.videoId !== root.requestedId) + return; + // A fetch in flight may predate the change; refresh once it has landed. + if (root.loading) { + root.stale = true; + return; + } + root.stale = false; + var id = now.videoId; + 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; + // A new model rebuilds the list at the top; land back where the reader was before + // the frame is drawn, on the active line unless they are scrolling by hand. + var keepY = view.contentY; + root.lyrics = l; + Qt.callLater(() => { + if (root.synced && !root.manualScroll && root.activeIndex >= 0) { + root.follow(root.activeIndex, false); + } else { + var maxY = view.originY + Math.max(0, view.contentHeight - view.height); + view.contentY = Math.max(view.originY, Math.min(keepY, maxY)); + } + }); + }).catch(() => {}); + } + + Connections { + target: Daemon + function onEvent(name, data) { + if (name !== "lyrics-changed") + return; + if (root.visible) + root.refreshInPlace(); + else + root.stale = true; + } + } + + 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 + (root.hasReadings ? Style.sp(4) : 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 } } + } + } + } + + // Latin reading (lyrics_romanize), tucked under its line: it shares the line's + // state colour (active, upcoming, past) a step softer, and the plain line's + // trailing leading is taken back so the two read as one cue. + Text { + Layout.fillWidth: true + Layout.topMargin: lineWrap.wordMode ? 0 : -Math.round(lineWrap.lineFs * 0.2) + visible: !!lineWrap.modelData.romanized + text: lineWrap.modelData.romanized ? lineWrap.modelData.romanized : "" + color: lineWrap.lineColor + opacity: 0.72 + font.family: Style.fontUi + font.pixelSize: root.compact ? Style.fs.xs : Style.fs.sm + wrapMode: Text.WordWrap + } + + // 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..2a4c5fe --- /dev/null +++ b/client/chrome/PlayerBar.qml @@ -0,0 +1,370 @@ +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 + // Menu wants a song row (video_id, …), which is the shape the queue holds. Playback.now is the + // player's own camelCase view of the same track, so the queue entry is the one to hand over. + readonly property var nowSong: { + var q = Playback.queue; + if (!q || !q.items || q.currentIndex < 0 || q.currentIndex >= q.items.length) + return null; + return q.items[q.currentIndex]; + } + 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 { + id: nowZone + Layout.preferredWidth: root.zoneW + Layout.fillWidth: false + spacing: Style.sp(3) + + // The playing track answers a right-click like any row in a list does. + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: (eventPoint) => { + if (!root.nowSong) + return; + var p = nowMenu.mapFromItem(null, eventPoint.scenePosition.x, eventPoint.scenePosition.y); + nowMenu.song = root.nowSong; + nowMenu.openAt(p.x, p.y); + } + } + + 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. Ids.sourceGlyph is the one place + // that knows the id spaces, so a new provider does not need a fourth hand + // written prefix chain here. + Icon { + visible: !!root.now && name !== "" + name: root.now ? Ids.sourceGlyph(String(root.now.videoId)) : "" + 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 + } + } + } + + Menu { id: nowMenu } + +} diff --git a/client/chrome/QueuePanel.qml b/client/chrome/QueuePanel.qml new file mode 100644 index 0000000..0f9bc4e --- /dev/null +++ b/client/chrome/QueuePanel.qml @@ -0,0 +1,565 @@ +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")); + } + // One menu for the panel, opened on a right-click anywhere in it. `backend` is the index the + // removal acts on, or -1 for the playing track, which the queue cannot drop out from under + // itself. + function openRowMenu(song, backend, sx, sy) { + if (!song) + return; + var p = queueMenu.mapFromItem(null, sx, sy); + queueMenu.song = song; + queueMenu.canRemove = backend >= 0; + queueMenu._backend = backend; + queueMenu.openAt(p.x, p.y); + } + + 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() + signal menuRequested(real sx, real sy) + + 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() } + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: (eventPoint) => qr.menuRequested(eventPoint.scenePosition.x, eventPoint.scenePosition.y) + } + } + + 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) + onMenuRequested: (sx, sy) => root.openRowMenu(sRow.modelData, root.matches[sRow.index].i, sx, sy) + } + } + 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) + onMenuRequested: (sx, sy) => root.openRowMenu(root.nowItem, -1, sx, sy) + } + + 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) + onMenuRequested: (sx, sy) => root.openRowMenu(wrap.modelData, root.currentIndex + 1 + wrap.index, sx, sy) + } + } + } + + 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() + } + } + } + } + } + + Menu { + id: queueMenu + property int _backend: -1 + canAdd: true + removeLabel: "Remove from queue" + onRemoveRequested: { + if (queueMenu._backend >= 0) + root.removeBackend(queueMenu._backend); + } + } + +} 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..5a02d57 --- /dev/null +++ b/client/chrome/TitleBar.qml @@ -0,0 +1,285 @@ +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 + // Above the page below it, so a hover label that hangs under the bar is not + // painted over by the sibling drawn after it. + z: 10 + + 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) + tip: root.sidebarOpen ? "Hide the rail" : "Show the rail" + tipBelow: true + 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) + tip: "Back" + tipBelow: true + enabled: Router.canGoBack + onClicked: Router.pop() + } + IconButton { + icon: "arrow-right" + iconSize: Style.fs.lg + diameter: Style.sp(8) + tip: "Forward" + tipBelow: true + 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" }, + { key: "tidal", icon: "tidal" } + ] + 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) + tip: "Clear the search" + tipBelow: true + 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) + tip: "Listen Together" + tipBelow: true + active: !!(Playback.lt && Playback.lt.role && Playback.lt.role !== "none") + onClicked: root.listenTogetherClicked() + } + IconButton { + icon: "discord" + iconSize: Style.fs.lg + diameter: Style.sp(8) + tip: root.discordOn ? "Discord rich presence is on" : "Discord rich presence is off" + tipBelow: true + 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) + tip: root.panelOpen ? "Hide the panel" : "Show the panel" + tipBelow: true + active: root.panelOpen + onClicked: root.panelToggleClicked() + } + IconButton { + icon: "sound" + iconSize: Style.fs.lg + diameter: Style.sp(8) + tip: "Sound" + tipBelow: true + onClicked: root.soundClicked() + } + IconButton { + icon: "minimize" + iconSize: Style.fs.lg + diameter: Style.sp(8) + tip: "Mini player" + tipBelow: true + 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..45f9c19 --- /dev/null +++ b/client/components/CardGrid.qml @@ -0,0 +1,72 @@ +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 { + id: card + onMenuRequested: (sx, sy) => root.openCardMenu(card, sx, sy) + 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 + } + + + ItemMenu { + id: cardMenu + playItem: (it) => cardMenu.card ? cardMenu.card.playNow() : undefined + openItem: (it) => cardMenu.card ? cardMenu.card.open() : undefined + property var card: null + } + + function openCardMenu(card, sx, sy) { + if (!card || !card.item) + return; + cardMenu.card = card; + cardMenu.openFor(card.item, sx, sy); + } + +} 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..404e929 --- /dev/null +++ b/client/components/HomePersonal.qml @@ -0,0 +1,492 @@ +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) } + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: (eventPoint) => recentMenu.openFor(recRow.modelData, + eventPoint.scenePosition.x, eventPoint.scenePosition.y) + } + } + } + } + } + + // ── 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) + } + } + } + } + } + + ItemMenu { + id: recentMenu + playItem: (it) => root.playRecent(it) + openItem: (it) => root.openRecent(it) + } + +} 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..3d33ad9 --- /dev/null +++ b/client/components/IconButton.qml @@ -0,0 +1,99 @@ +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 + // The hover label, and the name assistive tech reads. It was declared here + // from the start and never drawn, so every tip: in the app set a property + // nothing rendered. + property string tip: "" + // Draw the label under the button instead of over it, for a row that sits + // against the top of the window where there is no room above. + property bool tipBelow: false + property color iconColor: root.primary ? Tokens.inkOnBone + : !root.enabled ? Tokens.inkFaint + : root.active ? Style.accent + : 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 + } + + // Hover label. One line, elided; fades unless reduced motion is asked for. + Rectangle { + id: tipPlate + visible: opacity > 0 + opacity: (hover.hovered && root.tip !== "") ? 1 : 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: root.tipBelow ? parent.bottom : undefined + anchors.bottom: root.tipBelow ? undefined : parent.top + anchors.topMargin: Style.sp(1.5) + 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.tip + textFormat: Text.PlainText + color: Tokens.ink + font.family: Style.fontUi + font.pixelSize: Style.fs.xs + elide: Text.ElideRight + } + } + + Accessible.role: Accessible.Button + Accessible.name: root.tip + Accessible.onPressAction: root.clicked() + + 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/ItemMenu.qml b/client/components/ItemMenu.qml new file mode 100644 index 0000000..ddb040d --- /dev/null +++ b/client/components/ItemMenu.qml @@ -0,0 +1,43 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Ryoku.Ui.Singletons +import "../" +import "../lib/browse.js" as Browse + +// The context menu for anything a browse item can be: a card on a shelf, a row in "Recently played", +// a search result. A song gets the track menu, because that is what it is. An album, artist or +// playlist is not a track, so it gets the three actions that do mean something for it, run through +// the host's own play/open functions rather than a second copy of them. +Item { + id: root + + // function(item) -> void. The host's own handlers, so a card plays the way a card plays. + property var playItem: null + property var openItem: null + // Shown only when the host can pin things. + property bool canPin: true + + function openFor(item, sx, sy) { + if (!item) + return; + var p = menu.mapFromItem(null, sx, sy); + if (item.kind === "song") { + menu.customItems = null; + menu.song = Browse.asSong(item); + } else { + menu.song = null; + let out = []; + if (root.playItem) + out.push({ icon: "play", label: "Play", danger: false, act: () => root.playItem(item) }); + if (root.openItem) + out.push({ icon: "arrow-right", label: "Open", danger: false, act: () => root.openItem(item) }); + if (root.canPin) + out.push({ icon: "dashboard", label: "Add to shortcuts", danger: false, + act: () => Playback.toast(Personal.addPick(item) ? "Added to shortcuts" : "Already in shortcuts", "success") }); + menu.customItems = out; + } + menu.openAt(p.x, p.y); + } + + Menu { id: menu } +} diff --git a/client/components/LibrarySongs.qml b/client/components/LibrarySongs.qml new file mode 100644 index 0000000..4db6f22 --- /dev/null +++ b/client/components/LibrarySongs.qml @@ -0,0 +1,240 @@ +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, + shuffle: start === null ? false : undefined + }).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..4d76756 --- /dev/null +++ b/client/components/MediaCard.qml @@ -0,0 +1,170 @@ +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, shuffle: false })) + .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, + shuffle: false + })) + .catch(() => Playback.toast("Could not play — try opening it", "error")); + } + } + + signal menuRequested(real sx, real sy) + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + let p = mapToItem(null, mouse.x, mouse.y); + root.menuRequested(p.x, p.y); + return; + } + 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" + } + + // where it comes from: top-left, clear of the hover play; on an artist circle it sits + // on the rim at 45° instead of hanging off the empty corner + ProviderBadge { + id: badge + itemId: root.item ? root.item.id : "" + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: root.round ? Math.max(0, Math.round(parent.width * 0.146 - badge.width / 2)) + : Style.sp(2) + } + + // 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..38e3ea2 --- /dev/null +++ b/client/components/Menu.qml @@ -0,0 +1,216 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Window +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 + + // The menu lives on the window, not inside the list that opened it: its dismiss layer has to + // cover everything a click can land on, and a list that clips its children would otherwise cut + // the panel off. Callers map the press into this item's space with mapFromItem(null, ...), + // which stays correct because the window's content item is the scene. + parent: root.Window.window ? root.Window.window.contentItem : undefined + 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)) + // The track this menu is about is the one playing. "Play next" and "Add to queue" are a no-op + // on it, so they are left out rather than offered and ignored. + readonly property bool isNowPlaying: !!root.song && !!Playback.now + && Playback.now.videoId === root.song.video_id + // A host can force the queue actions back on (the same song sitting twice in a queue). + property bool canQueue: !root.isNowPlaying + + 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"; + } + + // Where the press landed, in this item's space. The placement is a binding on the panel rather + // than a calculation here, because the panel's height follows the items the song produces and + // is not known yet at the moment it is asked for: reading it here placed a menu opened near the + // bottom of the window half off-screen. + property real askedX: 0 + property real askedY: 0 + + function openAt(sx, sy) { + if (!root.song && !root.customItems) + return; + root.askedX = sx; + root.askedY = sy; + panel.visible = true; + } + 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 = []; + if (root.canQueue) { + 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() + + // The menu sits on the window, so a list scrolling underneath would leave it hanging over + // a row it no longer belongs to. The wheel closes it instead. + WheelHandler { + enabled: root.open + // WheelHandler is the one handler Qt narrows to PointerDevice.Mouse, and the Wayland + // plugin stamps wheel events with the seat's primary pointing device, which is reported + // as a touchpad on machines that have no touchpad at all. Left on the default this + // never fires, with no warning to say so. + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: root.close() + } + } + + Rectangle { + id: panel + visible: false + width: Style.sp(54) + implicitHeight: col.implicitHeight + Style.sp(2) + height: implicitHeight + x: Math.max(Style.sp(2), Math.min(root.askedX, root.width - panel.width - Style.sp(2))) + y: (root.askedY + panel.height > root.height - Style.sp(2)) + ? Math.max(Style.sp(2), root.askedY - panel.height) + : root.askedY + 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/ProviderBadge.qml b/client/components/ProviderBadge.qml new file mode 100644 index 0000000..ee1a807 --- /dev/null +++ b/client/components/ProviderBadge.qml @@ -0,0 +1,31 @@ +import QtQuick +import "../" +import "../lib/ids.js" as Ids + +// The source tag a card's artwork wears: the catalogue's glyph in its own colour on a dark chip, +// so YouTube Music, Spotify and SoundCloud items tell apart on any cover. Hidden for Ryotunes' own +// collections, which come from no catalogue. +Rectangle { + id: root + + property string itemId: "" + property int glyphSize: Style.sp(4) + + readonly property string glyph: Ids.sourceGlyph(root.itemId) + + visible: root.glyph !== "" + width: root.glyphSize + Style.sp(2) + height: width + radius: width / 2 + // A fixed dark chip, not a skin token: it sits on arbitrary artwork and has to read on all of it. + color: Qt.rgba(0, 0, 0, 0.6) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.14) + + Icon { + anchors.centerIn: parent + name: root.glyph + size: root.glyphSize + color: Style.providerColors[Ids.providerOf(root.itemId)] || "white" + } +} 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..b8adc72 --- /dev/null +++ b/client/components/Shelf.qml @@ -0,0 +1,92 @@ +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 { + id: card + onMenuRequested: (sx, sy) => root.openCardMenu(card, sx, sy) + item: cell.modelData + cardWidth: Style.cardW + } + } + } + } + } + } + + + ItemMenu { + id: cardMenu + playItem: (it) => cardMenu.card ? cardMenu.card.playNow() : undefined + openItem: (it) => cardMenu.card ? cardMenu.card.open() : undefined + property var card: null + } + + function openCardMenu(card, sx, sy) { + if (!card || !card.item) + return; + cardMenu.card = card; + cardMenu.openFor(card.item, sx, sy); + } + +} 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..a85eb3a --- /dev/null +++ b/client/components/TrackRow.qml @@ -0,0 +1,293 @@ +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 : "" + // The catalogue the track comes from, as a glyph in that catalogue's colour beside the title. + readonly property string sourceGlyph: song ? Ids.sourceGlyph(song.video_id) : "" + readonly property color sourceColor: Style.providerColors[song ? Ids.providerOf(song.video_id) : ""] || Tokens.inkMuted + + 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 } + } + Icon { + visible: root.sourceGlyph !== "" + name: root.sourceGlyph + size: Style.sp(3.5) + color: root.sourceColor + } + } + 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 } + } + Icon { + visible: root.sourceGlyph !== "" + name: root.sourceGlyph + size: Style.sp(3.5) + color: root.sourceColor + } + } + + // 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..f842109 --- /dev/null +++ b/client/lib/glyphs.js @@ -0,0 +1,373 @@ +.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 + }, + "tidal": { + "d": "M4 14L8 10L12 14L8 18Z M8 10L12 6L16 10L12 14Z M12 14L16 10L20 14L16 18Z", + "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..4f8a6f7 --- /dev/null +++ b/client/lib/ids.js @@ -0,0 +1,63 @@ +.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"; + if (id.indexOf("tidal:") === 0) + return "tidal"; + return "youtube"; +} + +// The catalogue glyph a card or row wears so its source reads at a glance: "youtube-music", +// "spotify" or "soundcloud". Ryotunes' own collections (smart playlists, local playlists, files +// on disk, radio) come from no catalogue and get "". +function sourceGlyph(id) { + switch (providerOf(id)) { + case "spotify": + return "spotify"; + case "soundcloud": + return "soundcloud"; + case "tidal": + return "tidal"; + case "youtube": + return isSmartPlaylistId(id) || id.indexOf("RYOTUNES_") === 0 ? "" : "youtube-music"; + default: + return ""; + } +} 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..d3cd764 --- /dev/null +++ b/client/lib/playback.js @@ -0,0 +1,223 @@ +.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 "tidal-auth": { + // The TIDAL device flow's progress. TIDAL shows a short code the user types on its own + // site, so "waiting" carries the code and the page to open; the rest mirror SoundCloud. + var td = (data && data.state) ? data.state : ""; + var keep = s.tidal || {}; + if (td === "signed_in") { + s.tidal = { signedIn: true, name: (data && data.name) ? data.name : null, + quality: (data && data.quality) ? data.quality : (keep.quality || null), + hasCredentials: true, code: "", url: "", error: "" }; + return { toast: "Signed in to TIDAL" + ((data && data.name) ? (" as " + data.name) : ""), kind: "success" }; + } + if (td === "signed_out") { + s.tidal = { signedIn: false, name: null, quality: null, + hasCredentials: !!keep.hasCredentials, code: "", url: "", error: "" }; + return null; + } + if (td === "waiting") { + s.tidal = { signedIn: false, name: null, quality: null, hasCredentials: true, + code: (data && data.code) ? data.code : "", + url: (data && data.url) ? data.url : "", error: "" }; + return { + toast: "Enter " + ((data && data.code) ? data.code : "the code") + " at " + ((data && data.url) ? data.url : "link.tidal.com") + " to finish signing in", + kind: "info", openUrl: s.tidal.url + }; + } + if (td === "expired") { + s.tidal = { signedIn: false, name: null, quality: null, hasCredentials: true, + code: "", url: "", error: "" }; + return { toast: "The TIDAL code expired before it was approved", kind: "info" }; + } + s.tidal = { signedIn: false, name: null, quality: null, + hasCredentials: !!keep.hasCredentials, code: "", url: "", + error: (data && data.message) ? data.message : "" }; + return (data && data.message) ? { toast: data.message, kind: "error" } : 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..5c70fdc --- /dev/null +++ b/client/mini/MiniPlayer.qml @@ -0,0 +1,539 @@ +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 + // Menu wants a song row; the queue holds the playing track in that shape. + readonly property var nowSong: { + var q = Playback.queue; + if (!q || !q.items || q.currentIndex < 0 || q.currentIndex >= q.items.length) + return null; + return q.items[q.currentIndex]; + } + 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() } + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: (eventPoint) => { + if (!root.nowSong) + return; + var p = miniMenu.mapFromItem(null, eventPoint.scenePosition.x, eventPoint.scenePosition.y); + miniMenu.song = root.nowSong; + miniMenu.openAt(p.x, p.y); + } + } + 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 ? Style.accent : 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 ? Style.accent : 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) + } + } + + Menu { id: miniMenu } + +} diff --git a/client/pages/AlbumPage.qml b/client/pages/AlbumPage.qml new file mode 100644 index 0000000..8cac6e0 --- /dev/null +++ b/client/pages/AlbumPage.qml @@ -0,0 +1,364 @@ +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, + shuffle: start === null ? false : undefined + }).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..84d90ce --- /dev/null +++ b/client/pages/DownloadSettings.qml @@ -0,0 +1,278 @@ +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" }, { key: "flac", label: "FLAC" }] + 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." + : root.formatInput === "flac" ? "FLAC for players and libraries that expect FLAC files. No further lossy compression is applied, but the audio is only as good as the lossy source, and files are several times larger." + : "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..00b8544 --- /dev/null +++ b/client/pages/HomePage.qml @@ -0,0 +1,730 @@ +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) + + // TIDAL has no guest catalogue, so a signed-out TIDAL reaches this page with an empty feed + // rather than an error. Without this the empty state offers Google's sign-in, which signs the + // user into the wrong service entirely and leaves the selected provider exactly as dead. + readonly property bool tidalGate: Playback.provider === "tidal" + && !(Playback.tidal && Playback.tidal.signedIn) + 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: page.tidalGate + ? "Sign in to TIDAL and home fills up with your playlists, albums and tracks." + : (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: page.tidalGate ? "Sign in to TIDAL" + : (Playback.auth && Playback.auth.signedIn) ? "Try again" : "Sign in with Google" + active: page.tidalGate || !(Playback.auth && Playback.auth.signedIn) + onClicked: page.tidalGate + ? Playback.tidalSignIn().catch(() => {}) + : (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..dc3d55b --- /dev/null +++ b/client/pages/PlaylistPage.qml @@ -0,0 +1,570 @@ +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, + shuffle: start === null ? false : undefined + }).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..b0b9892 --- /dev/null +++ b/client/pages/SettingsPage.qml @@ -0,0 +1,1557 @@ +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; + // Already on disk (a system upgrade got there first): nothing to download, just restart. + if (info.restartPending) { + page.installedVersion = version; + page.updateStatus = "installed"; + page.restartIntoUpdate(); + return; + } + 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) { + if (info.restartPending) + return "Ryotunes " + info.latestVersion + " is already installed. Restart Ryotunes to start using it."; + 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") + } + ToggleRow { + title: "Romanized lyrics" + desc: "Shows a Latin reading under Korean, Chinese, Cyrillic, Hindi and Punjabi lyrics. Worked out on this device." + value: page.settings.lyrics_romanize === "true" + onFlipped: (on) => page.setSetting("lyrics_romanize", 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")); + } + } + } + + // ── TIDAL ─────────────────────────────────────────────────────────── + SectionHeading { Layout.fillWidth: true; Layout.topMargin: Style.sp(3); title: "TIDAL"; mark: "潮" } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Icon { + name: "tidal" + size: Style.fs.hero + color: Playback.provider === "tidal" ? Style.providerColors.tidal : Tokens.inkMuted + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + readonly property var td: Playback.tidal || ({}) + text: (td.signedIn && td.name) ? td.name : (td.signedIn ? "Signed in" : "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 td: Playback.tidal || ({}) + text: td.code ? ("Enter " + td.code + " at " + (td.url || "link.tidal.com")) + : td.signedIn ? ("Connected \u00b7 best reachable quality " + (td.quality || "unknown")) + : td.error ? td.error + : "TIDAL has no guest catalogue \u00b7 sign in to browse and play." + color: (td.error && !td.signedIn) ? Style.accent : Tokens.inkMuted + font.family: Style.fontUi; font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap; Layout.fillWidth: true + } + } + Pill { + readonly property var td: Playback.tidal || ({}) + label: td.signedIn ? "Sign out" : "Sign in to TIDAL" + icon: td.signedIn ? "close" : "tidal" + primary: !td.signedIn + onClicked: { + var out = !!(Playback.tidal && Playback.tidal.signedIn); + var call = out ? Playback.tidalSignOut() : Playback.tidalSignIn(); + 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…" + : (page.updateInfo && page.updateInfo.restartPending) ? "Restart to finish update" + : ("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_ids.qml b/client/tests/tst_ids.qml new file mode 100644 index 0000000..2ad760b --- /dev/null +++ b/client/tests/tst_ids.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtTest +import "../lib/ids.js" as Ids + +// The source tag on cards and rows: every catalogue id wears its catalogue's glyph, and Ryotunes' +// own collections (which share YouTube's unprefixed id space in providerOf) wear none, so a smart or +// local playlist never claims to come from YouTube Music. +TestCase { + name: "Ids" + + function test_catalogue_ids_wear_their_glyph() { + compare(Ids.sourceGlyph("dQw4w9WgXcQ"), "youtube-music"); + compare(Ids.sourceGlyph("MPREb_abc"), "youtube-music"); + compare(Ids.sourceGlyph("spotify:album:4uLU6hMCjMI75M1A2tKUQC"), "spotify"); + compare(Ids.sourceGlyph("spotify:playlist:liked"), "spotify"); + compare(Ids.sourceGlyph("sc:playlist:123"), "soundcloud"); + compare(Ids.sourceGlyph("sc:system:weekly"), "soundcloud"); + compare(Ids.sourceGlyph("tidal:track:12345"), "tidal"); + compare(Ids.sourceGlyph("tidal:playlist:1f-2e-3d"), "tidal"); + } + + // A tidal id must not fall through to YouTube the way an unprefixed id does: the fallthrough + // is what makes a missed prefix a silent wrong badge rather than an error. + function test_tidal_ids_are_their_own_provider() { + compare(Ids.providerOf("tidal:track:1"), "tidal"); + compare(Ids.providerOf("tidal:album:1"), "tidal"); + compare(Ids.providerOf("tidalish"), "youtube"); + } + + function test_ryotunes_own_collections_wear_none() { + compare(Ids.sourceGlyph("RYOTUNES_ON_REPEAT"), ""); + compare(Ids.sourceGlyph("RYOTUNES_REDISCOVER"), ""); + compare(Ids.sourceGlyph("RYOTUNES_LOCAL_PLAYLIST:42"), ""); + compare(Ids.sourceGlyph("RYOTUNES_RADIO:station"), ""); + compare(Ids.sourceGlyph("LOCALALBUM:x"), ""); + compare(Ids.sourceGlyph(""), ""); + compare(Ids.sourceGlyph(undefined), ""); + } +} 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..23a281d --- /dev/null +++ b/client/tests/tst_playback.qml @@ -0,0 +1,157 @@ +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. + // The TIDAL device flow carries a code the user types elsewhere, so "waiting" has to keep it + // where the settings page can show it; every other state clears it again. + function test_tidal_device_flow_carries_its_code() { + var s = { tidal: { signedIn: false, hasCredentials: true } }; + var waiting = PB.applyEvent(s, "tidal-auth", + { state: "waiting", code: "ABCD", url: "https://link.tidal.com/ABCD" }); + compare(s.tidal.code, "ABCD"); + compare(s.tidal.url, "https://link.tidal.com/ABCD"); + verify(waiting && waiting.toast.indexOf("ABCD") >= 0); + compare(waiting.openUrl, "https://link.tidal.com/ABCD"); + + // The ceiling rides along on the sign-in, because nothing re-reads it afterwards: without + // it the settings page says the best reachable quality is unknown for the whole session. + var done = PB.applyEvent(s, "tidal-auth", + { state: "signed_in", name: "vlad", quality: "LOSSLESS" }); + compare(s.tidal.signedIn, true); + compare(s.tidal.name, "vlad"); + compare(s.tidal.quality, "LOSSLESS"); + compare(s.tidal.code, ""); + verify(done && done.kind === "success"); + + PB.applyEvent(s, "tidal-auth", { state: "signed_out" }); + compare(s.tidal.signedIn, false); + compare(s.tidal.hasCredentials, true, "signing out must not forget the client id"); + } + + // The expiry TIDAL declares itself has to read the same as the one the daemon times out on. + function test_tidal_expiry_reads_the_same_whoever_declares_it() { + var s = { tidal: { signedIn: false, hasCredentials: true } }; + PB.applyEvent(s, "tidal-auth", { state: "waiting", code: "ABCD", url: "u" }); + var gone = PB.applyEvent(s, "tidal-auth", { state: "expired" }); + compare(s.tidal.code, "", "an expired code must not stay on screen"); + compare(s.tidal.signedIn, false); + verify(gone && gone.toast.indexOf("expired") >= 0); + } + + 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..0495c4a --- /dev/null +++ b/client/version @@ -0,0 +1 @@ +1.2.3 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..dab2ff1 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,46 @@ +[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" } +ryotunes-tidal = { path = "../tidal" } +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" +# Toneless pinyin for lyrics romanization (`romanize.rs`); the plain table only. +pinyin = { version = "0.11", default-features = false, features = ["plain"] } +url = "2" +sha2 = "0.10" +tempfile = "3" +# Void self-update: unpack the release .pkg.tar.zst so it can be repacked with xbps-create. +tar = { version = "0.4", default-features = false } +ruzstd = "0.9" + +[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..8acba30 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,24 @@ +//! 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 romanize; +pub mod session; +pub mod soundcloud_bridge; +pub mod spotify; +pub mod spotify_bridge; +pub mod state; +pub mod tidal_bridge; +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 93% rename from src-tauri/src/lyrics.rs rename to crates/core/src/lyrics.rs index e94ae71..586ba00 100644 --- a/src-tauri/src/lyrics.rs +++ b/crates/core/src/lyrics.rs @@ -46,11 +46,14 @@ pub struct LyricLine { pub words: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub translation: Option, + /// Latin reading of `text` (`romanize.rs`). Only filled on the way to a client, never cached. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub romanized: Option, } impl LyricLine { pub fn simple(time_ms: Option, text: String) -> Self { - Self { time_ms, end_time_ms: None, text, words: None, translation: None } + Self { time_ms, end_time_ms: None, text, words: None, translation: None, romanized: None } } } @@ -85,6 +88,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 +111,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 @@ -878,6 +923,7 @@ fn parse_lrc_or_ttml(text: &str) -> Vec { text: line_text, words: if !words.is_empty() { Some(words) } else { None }, translation: None, + romanized: None, }); } } @@ -977,6 +1023,7 @@ fn parse_ttml_aaml(xml: &str) -> Vec { text: full_text, words: words_opt, translation: None, + romanized: None, }); } } @@ -1128,6 +1175,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"; @@ -1255,6 +1333,7 @@ mod tests { text: "Hello world".into(), words: Some(vec![LyricWord { text: "Hello ".into(), start_ms: 10100, end_ms: 12000 }]), translation: Some("Halo dunia".into()), + romanized: None, }]; let muxed = lrc_mux(primary, word_source); assert_eq!(muxed.len(), 1); 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/romanize.rs b/crates/core/src/romanize.rs new file mode 100644 index 0000000..0a80271 --- /dev/null +++ b/crates/core/src/romanize.rs @@ -0,0 +1,1245 @@ +//! Lyrics romanization: a Latin reading under each line for scripts the listener may not read. +//! +//! Ported from PeekMusic/PeekMusic (`app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt`, +//! GPL-3.0), commit 83217188a, itself a fork of MetrolistGroup/Metrolist. Korean, Cyrillic, +//! Devanagari and Gurmukhi are table- and rule-based; Chinese uses the MIT-licensed `pinyin` crate. +//! Japanese is deliberately not handled yet (it needs a morphological dictionary), so a Japanese +//! song is left untouched rather than read as Chinese. +//! +//! The script is decided once for the whole song, not per line: a line of only kanji inside a +//! Japanese song must not become pinyin, and a short Cyrillic line carries too few letters to +//! tell Russian from Ukrainian. Romanization runs when lyrics are handed to a client, never on +//! the cached or downloaded copy. + +use pinyin::ToPinyin; + +use crate::lyrics::Lyrics; +use crate::state::AppState; + +/// The `get_lyrics` reply as a client should see it: romanized when `lyrics_romanize` is on. +pub fn for_client(state: &AppState, lyrics: Option) -> Option { + let mut lyrics = lyrics?; + if state.db.get_setting("lyrics_romanize").as_deref() == Some("true") { + romanize_lyrics(&mut lyrics); + } + Some(lyrics) +} + +/// Fill `romanized` on every line that has a reading different from its text. +pub fn romanize_lyrics(lyrics: &mut Lyrics) { + let all: String = + lyrics.lines.iter().map(|line| line.text.as_str()).collect::>().join("\n"); + let Some(script) = detect(&all) else { + return; + }; + for line in &mut lyrics.lines { + let romanized = romanize_line(script, &line.text); + line.romanized = (!romanized.trim().is_empty() && !same_text(&romanized, &line.text)) + .then(|| romanized.trim().to_owned()); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Script { + Korean, + Chinese, + Cyrillic(Cyrillic), + Devanagari, + Gurmukhi, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cyrillic { + Russian, + Ukrainian, + Belarusian, + Serbian, + Macedonian, + Bulgarian, + Kyrgyz, +} + +fn is_hangul(c: char) -> bool { + ('\u{AC00}'..='\u{D7A3}').contains(&c) +} + +fn is_kana(c: char) -> bool { + ('\u{3040}'..='\u{30FF}').contains(&c) +} + +fn is_han(c: char) -> bool { + ('\u{4E00}'..='\u{9FFF}').contains(&c) || ('\u{3400}'..='\u{4DBF}').contains(&c) +} + +fn is_cyrillic(c: char) -> bool { + ('\u{0400}'..='\u{04FF}').contains(&c) +} + +fn is_devanagari(c: char) -> bool { + ('\u{0900}'..='\u{097F}').contains(&c) +} + +fn is_gurmukhi(c: char) -> bool { + ('\u{0A00}'..='\u{0A7F}').contains(&c) +} + +/// The song's dominant non-Latin script, or `None` when there is nothing to romanize (Latin-only +/// lyrics, or Japanese). +fn detect(text: &str) -> Option + + + "#; + 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..97689f3 --- /dev/null +++ b/crates/spotify/README.md @@ -0,0 +1,203 @@ +# 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`). + +### End of track and seeking + +The daemon's mpv runs with `cache=yes`, so it drains the FIFO into its seekable demuxer cache and +librespot finishes decoding a track within seconds of it starting. Two consequences: + +- librespot does **not** close its sink after the last packet (it expects a next `load`), so mpv + would wait on the pipe forever. `next_event()` closes it (`Player::stop`) on this handle's own + `EndOfTrack`; mpv plays out its cache, hits EOF, and the daemon advances the queue as usual. +- A seek is a plain mpv seek within that cache. librespot has already left its playing state, so + `StreamHandle::seek` would be rejected there. + +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..adeb04e --- /dev/null +++ b/crates/spotify/src/collection2.rs @@ -0,0 +1,272 @@ +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 token.is_empty() { + break; + } + } + + newest_first(&mut found, limit); + Ok(found) +} + +fn newest_first(found: &mut Vec, limit: usize) { + found.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + found.truncate(limit); +} + +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 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()); + } + + fn saved(uri: &str, added_at: Option) -> SavedItem { + SavedItem { uri: uri.to_owned(), added_at } + } + + #[test] + fn orders_saved_items_newest_first() { + let mut found = vec![ + saved("spotify:track:a", Some(100)), + saved("spotify:track:b", None), + saved("spotify:track:c", Some(300)), + saved("spotify:track:d", Some(200)), + ]; + + newest_first(&mut found, 4); + + let uris: Vec<_> = found.iter().map(|item| item.uri.as_str()).collect(); + assert_eq!( + uris, + ["spotify:track:c", "spotify:track:d", "spotify:track:a", "spotify:track:b"] + ); + } + + #[test] + fn keeps_the_newest_when_the_limit_cuts() { + let mut found = vec![ + saved("spotify:track:oldest", Some(1)), + saved("spotify:track:newest", Some(9)), + saved("spotify:track:middle", Some(5)), + ]; + + newest_first(&mut found, 2); + + let uris: Vec<_> = found.iter().map(|item| item.uri.as_str()).collect(); + assert_eq!(uris, ["spotify:track:newest", "spotify:track:middle"]); + } + + #[test] + fn undated_items_keep_their_order() { + let mut found = vec![saved("spotify:track:z", None), saved("spotify:track:a", None)]; + + newest_first(&mut found, 2); + + let uris: Vec<_> = found.iter().map(|item| item.uri.as_str()).collect(); + assert_eq!(uris, ["spotify:track:z", "spotify:track:a"]); + } +} 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..aa7686f --- /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::{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.