diff --git a/.github/workflows/native-nix.yml b/.github/workflows/native-nix.yml new file mode 100644 index 00000000..af4c35b5 --- /dev/null +++ b/.github/workflows/native-nix.yml @@ -0,0 +1,38 @@ +name: RyotunesNix Native Build + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-build: + runs-on: ubuntu-24.04 + timeout-minutes: 90 + + steps: + - uses: actions/checkout@v4 + + - uses: cachix/install-nix-action@v31 + + - name: Validate flake + run: nix flake show + + - name: Build native Ryotunes + run: nix build .#ryotunes --no-link -L + + - name: Verify native launcher + run: | + set -euo pipefail + + out="$(nix path-info .#ryotunes)" + + test -x "$out/bin/ryotunes" + test -x "$out/bin/ryotunesd" + test -x "$out/bin/ryotunes-cli" + test -f "$out/share/ryotunes/client/shell.qml" + + grep -Fq '/bin/qs' "$out/bin/ryotunes" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f190e09b..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,298 +0,0 @@ -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/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..5b297658 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,68 @@ +# RyotunesNix flake packaging + +This is a source flake for the **RyotunesNix repository**, not a patch for upstream `ryotunes`. The package includes the Rust binaries, Quickshell UI, Ryoku QML modules, and the Audio Lab's Python/FFmpeg/PipeWire monitor helpers. + +The Spotify Soloist bridge is part of the source, but **Soloist is still a separate runtime**. The flake does not download a time-limited Soloist binary, collect credentials, or change the live playback daemon. + +## Apply to RyotunesNix only + +First merge the Spotify, Audio Lab and UI changes into `Ryoku-dev/ryotunes` without these packaging files. Keep the NixOS packaging in its own branch of `Ryoku-dev/RyotunesNix`. + +After merging and synchronizing the application source into the fork: + +```bash +cd ~/Projects/ryoku-sprint/ryotunesnix +git switch -c feat/nix-flake +unzip -o ~/Downloads/ryotunesnix-flake.zip -d . +git add flake.nix nix/package.nix nix/wrapper.nix INSTALL.md +nix-instantiate --parse flake.nix >/dev/null +nix flake lock +nix eval --raw .#packages.x86_64-linux.ryotunes-unwrapped.version +git status --short +``` + +`unzip` will copy only these four files. Run it on a fresh RyotunesNix-only branch, never on the upstream PR branch. + +The flake's `cargoLock.allowBuiltinFetchGit` uses the pinned Git revisions in Cargo.lock rather than a placeholder cargoHash. It can fetch these Git sources during Nix evaluation. The pnpm lockfile uses the same hash as the existing Ryoku-on-NixOS packaging, because the `ui/pnpm-lock.yaml` content has not changed. All inputs should be recorded by `flake.lock` and committed. + +## Test and deploy + +The source still needs a real Nix build and desktop playback check on the target NixOS system. Avoid a full second build until disk space permits and the source merge is complete. + +```bash +nix build .#ryotunes -L +``` + +Only after the package builds and the active Soloist process remains functional should Ryoku-on-NixOS be changed. + +## Ryoku-on-NixOS integration + +Use the fork's `ryotunes-unwrapped` output with **Ryoku's existing wrapper and socket-activated daemon**. This avoids two service definitions, two installed app copies, and duplicate QML packages. + +In Ryoku-on-NixOS `flake.nix`, replace `ryotunesSrc` with: + +```nix +ryotunesNix = { + url = "github:Ryoku-dev/RyotunesNix"; + inputs.nixpkgs.follows = "ryokuPackagesNixpkgs"; + inputs.ryoku.follows = "ryokuUpstream"; +}; +``` + +Pass `ryotunesNix` through the outputs arguments into `./nix/packages`. In `nix/packages/default.nix`, replace the old `ryotunesUnwrapped` import with: + +```nix +ryotunesUnwrapped = + ryotunesNix.packages.${pkgs.stdenv.hostPlatform.system}.ryotunes-unwrapped; +``` + +Keep `ryotunes = import ./ryoku-ryotunes.nix { ... };` intact, so Ryoku uses its own `ryokuQml` package. Keep `ryotunesd.socket` and `ryotunesd.service` and their existing paths. They will resolve through the replacement `ryoku-ryotunes` wrapper. Remove the obsolete pinned source input and old derivation only after validation. + +The system currently may also have a **temporary user-level systemd override** pointing to the Cargo build. Inspect it before removing it, and don't stop or replace the working Soloist process until the new Nix-owned executable has passed a playback check. + +## Current limitations + +* The flake has been prepared from repository sources and existing NixOS packaging, but not built in this environment. +* Soloist remains externally managed and has a limited-lifetime binary. +* The QML audio scope uses `pactl` and FFmpeg with your PipeWire default output monitor, not arbitrary audio capture. +* This packaging does not claim to route Soloist audio through the libmpv equalizer. diff --git a/README.md b/README.md index 61ff5b84..04b7cb10 100644 --- a/README.md +++ b/README.md @@ -1,356 +1,135 @@ -
+ Ryotunes icon -Ryotunes — Music, shaped for Ryoku - -
+

RyotunesNix

-Latest release -GPL-3.0-or-later -Linux x86_64 -Ryoku native -Audio only -
-Rust -Quickshell / QML -libmpv +

The native music app for Ryoku on NixOS.

-

+

+ Get started · + Features · + Spotify · + Skins · + Contribute +

-**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/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)
-
- -2026_08_27_02_44_21_screenshot - -

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. +## Made for the desktop you actually use +**RyotunesNix** brings [Ryotunes](https://github.com/Ryoku-dev/ryotunes) to [Ryoku on NixOS](https://github.com/Ryoku-dev/Ryoku-on-NixOS), with a **native Quickshell frontend** and a separate Rust playback daemon. Music and media state belong to the backend, not to a window you have to leave open. ---- +This repository is the **NixOS-focused edition**, not the legacy Tauri desktop launcher. Running `ryotunes` from this flake starts the native QML interface. -## Built for Ryoku, not merely compatible with it - -Ryotunes is a Linux-first desktop music application shaped around the way **Ryoku + Hyprland** actually behave. - -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. +## Features - - - - - + + + + + + + + + + + +
- -### Native where it matters -Audio stays in **libmpv**, outside the frontend renderer. MPRIS, media keys, tray controls, gapless playback and the active session remain native. - - - -### Ryoku in real time -**Follow System** consumes Ryoku's live Material-role palette. Named themes and wallpaper-derived colours retint both the main window and mini-player immediately. - - - -### Quiet in the background -The QML window can close while the daemon continues playback or active downloads. - -
+ Native interface
+ Quickshell and QML surfaces designed to sit naturally alongside the Ryoku desktop. +
+ Playback that lives beyond the window
+ A Rust ryotunesd daemon keeps playback and media state independent of the frontend. +
+ Music from multiple sources
+ Explore your library, artists, albums, playlists and supported music providers from one place. +
+ Audio Lab
+ Audio controls, visualisers and listening tools integrated into the native player. +
+ Skins that belong to Ryoku
+ Follow the system palette or choose a skin. Community skins can be managed through RyoStore. +
+ Downloads and personal listening
+ Save music locally, manage playlists and browse listening views without leaving the app. +
-> [!NOTE] -> Ryotunes is intentionally **audio-only**. It does not provide music-video playback. - ---- - -## The experience - -| Surface | What Ryotunes does | -|---|---| -| **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 - -Ryotunes does not implement a disconnected theme layer and then approximate Ryoku on top of it. The Linux build integrates with the shell directly. +
+ More interface previews +
+ Ryotunes interface preview one + Ryotunes interface preview two +
-- **Live palette bridge** — Rust resolves Ryoku Material roles and watches Ryoku theme sources with inotify. -- **Immediate theme updates** — no 60-second frontend palette polling loop. -- **Compositor-owned startup geometry** — the main surface is floated, sized and centred by the Ryoku/Hyprland rule before it becomes visible. -- **Mini-player isolation** — the main rule matches the exact title `^(Ryotunes)$`; `Ryotunes Mini` remains independent. -- **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`. +## Getting started -### Skins +### With Ryoku on NixOS -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: +RyotunesNix is integrated into the [Ryoku on NixOS](https://github.com/Ryoku-dev/Ryoku-on-NixOS) package bundle. On a supported Ryoku installation, launch it with: +```bash +ryotunes ``` -/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/ryoku-dev/ryoku-arch)** · **[Ryoku Discord](https://discord.gg/8KjBmUEyKA)** - ---- - -## A small lifecycle contract with a big payoff - -| Situation | Behaviour | -|---|---| -| 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. - ---- - -## Architecture - -```mermaid -flowchart LR - R["Ryoku / Hyprland"] --> M["MPRIS · media keys · tray"] - R --> T["live palette + window policy"] - - M --> N["ryotunesd / Rust daemon"] - T --> N - N --> P["native player crate"] - P --> MPV["libmpv · audio only"] +Ryoku manages the installed player and its socket-activated daemon as part of the desktop configuration. - N --> I["YouTube · Spotify · SoundCloud · downloads"] - N <-->|"Unix socket RPC + events"| W["Quickshell / QML"] +### Using the flake directly - W --> U["Home · Search · Library"] - W --> Q["Queue · Lyrics · Now Playing"] - W --> MINI["Ryotunes Mini"] +To try the native application from this repository: +```bash +nix run github:Ryoku-dev/RyotunesNix ``` -More detail: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - ---- - -## Performance is part of the product - -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; -- 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. - -The design rules behind the interface are documented in **[docs/DESIGN.md](docs/DESIGN.md)**. - ---- - -## Install - -Ryotunes targets **x86_64 Ryoku, CachyOS and Arch-based systems**, and Void Linux (see **[docs/INSTALL-VOID.md](docs/INSTALL-VOID.md)**). - -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 or Cargo. - -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: +Or build the package: ```bash -sha256sum -c ryotunes--1-x86_64.pkg.tar.zst.sha256 -sudo pacman -U ./ryotunes--1-x86_64.pkg.tar.zst +nix build github:Ryoku-dev/RyotunesNix#ryotunes ``` -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. +The flake currently targets **x86_64-linux**. A standalone flake invocation supplies the player but **does not automatically install the Ryoku NixOS user services**. The native client needs a running `ryotunesd` daemon. For the fully managed desktop experience, use the Ryoku on NixOS integration. -> [!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. +## Spotify Soloist -Source/development setup: **[docs/INSTALL-ARCH.md](docs/INSTALL-ARCH.md)** +Spotify playback through Soloist uses an **optional external backend**. RyotunesNix includes the local bridge, but it does not bundle the Soloist executable or any account credentials. ---- - -
-Repository map - -```text -crates/ - innertube/ YouTube Music request/model layer - player/ native playback core - listen-protocol/ shared Listen Together protocol - sync-server/ optional room relay - -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 -docs/ architecture, install, design and troubleshooting -aur/ binary AUR recipe sources -``` - -
- -
-Development & validation - -The dependency graphs are locked. Release work is expected to pass both frontend and native gates. +After obtaining your own permitted Soloist executable and API key, run the one-time setup: ```bash -cd ui -pnpm install --frozen-lockfile -pnpm check -pnpm build - -cd .. -cargo fmt --all -- --check -cargo test --workspace --locked -cargo check --workspace --locked -cargo tauri build --no-bundle +nix run 'github:Ryoku-dev/RyotunesNix#setup-soloist' -- /absolute/path/to/soloist ``` -Release checklist: **[docs/RELEASE-CHECKLIST.md](docs/RELEASE-CHECKLIST.md)** - -
- -
-Troubleshooting - -Start with **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)**. - -For a useful bug report, include: - -- Ryoku / Hyprland version -- distro and kernel -- Wayland/display-server context -- GPU setup -- steps to reproduce -- bundled diagnostics output when relevant - -
- ---- +The setup creates an opt-in systemd user service that starts Soloist on login. Your key stays on your machine, outside the Nix store. A running Soloist backend and `ryotunesd` are both required for this playback path. -## Contributing +See **[Spotify Soloist setup and troubleshooting](docs/spotify-soloist.md)** for the full instructions and security notes. -Contributions are welcome when they preserve the product contracts that make Ryotunes feel native on Ryoku. +## Inside the project -Start with **[CONTRIBUTING.md](CONTRIBUTING.md)**. Changes touching playback lifecycle, theme integration, Home rendering, background behaviour or replacement packaging deserve explicit regression testing. +| Path | Purpose | +| --- | --- | +| [`client/`](client/) | Native Quickshell UI and playback surfaces | +| [`crates/`](crates/) | Rust playback core, daemon, CLI and protocols | +| [`nix/`](nix/) | NixOS package and native launcher wrapper | +| [`skins/`](skins/) | Bundled visual skins and skin schema | +| [`docs/`](docs/) | Design, architecture and troubleshooting references | ---- +**Want to customise it?** Start with the [skin guide](docs/SKINS.md) and [design language](docs/DESIGN.md). For source changes, read [CONTRIBUTING.md](CONTRIBUTING.md). -## Upstream, license & independence +## Credits and licence -Ryotunes is a **GPL-3.0-or-later modified work derived from [SimoHypers/LiMusic](https://github.com/SimoHypers/limusic)**. +RyotunesNix is maintained as the NixOS-focused edition of [Ryotunes](https://github.com/Ryoku-dev/ryotunes), a modified work derived from [LiMusic](https://github.com/SimoHypers/limusic). -The project retains transparent upstream attribution while pursuing its own Ryoku-specific product direction, interface, lifecycle, performance architecture and packaging model. See **[UPSTREAM.md](UPSTREAM.md)** for the detailed note. - -Ryotunes is independently developed and is **not affiliated with, authorized by, sponsored by, or endorsed by YouTube or Google**. YouTube and YouTube Music are trademarks of Google LLC. - -Distributed under **[GPL-3.0-or-later](LICENSE)**. - ---- +Licensed under **[GPL-3.0-or-later](LICENSE)**. See [UPSTREAM.md](UPSTREAM.md) for attribution and project history.
- -### Music, shaped for the Ryoku desktop. - -Rust · Quickshell / QML · libmpv · Hyprland - +
+ 緑 · Ryoku +

Keep the music. Lose the clutter.

diff --git a/aur/.SRCINFO b/aur/.SRCINFO deleted file mode 100644 index 75182b21..00000000 --- a/aur/.SRCINFO +++ /dev/null @@ -1,22 +0,0 @@ -pkgbase = ryotunes-bin - pkgdesc = Ryoku-native desktop music player with native libmpv playback - pkgver = 2.2.0 - pkgrel = 1 - url = https://github.com/ashmitvoid/RYOTUNES - install = ryotunes-bin.install - arch = x86_64 - license = GPL-3.0-or-later - depends = webkit2gtk-4.1 - depends = libappindicator-gtk3 - depends = mpv - depends = openssl - depends = librsvg - depends = desktop-file-utils - depends = hicolor-icon-theme - depends = xdg-utils - provides = ryotunes=2.2.0 - conflicts = ryotunes-v2.2 - source_x86_64 = ryotunes-v2.2.0-linux-x86_64.tar.zst::https://github.com/ashmitvoid/RYOTUNES/releases/download/v2.2.0/ryotunes-v2.2.0-linux-x86_64.tar.zst - sha256sums_x86_64 = 2214e492c2402cf1f7ec6026cf94d630af6eafb2e272234876558bc0384493ac - -pkgname = ryotunes-bin diff --git a/aur/LICENSE b/aur/LICENSE deleted file mode 100644 index b90dac76..00000000 --- a/aur/LICENSE +++ /dev/null @@ -1,14 +0,0 @@ -BSD Zero Clause License - -Copyright (c) 2026 ashmitvoid - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/aur/PKGBUILD b/aur/PKGBUILD deleted file mode 100644 index ba2d0ee6..00000000 --- a/aur/PKGBUILD +++ /dev/null @@ -1,31 +0,0 @@ -# Maintainer: ashmitvoid <52139594+ashmitvoid at users dot noreply dot github dot com> -pkgname=ryotunes-bin -pkgver=2.2.0 -pkgrel=1 -pkgdesc='Ryoku-native desktop music player with native libmpv playback' -arch=('x86_64') -url='https://github.com/ashmitvoid/RYOTUNES' -license=('GPL-3.0-or-later') - -depends=( - 'webkit2gtk-4.1' - 'libappindicator-gtk3' - 'mpv' - 'openssl' - 'librsvg' - 'desktop-file-utils' - 'hicolor-icon-theme' - 'xdg-utils' -) - -provides=('ryotunes=2.2.0') -conflicts=('ryotunes-v2.2') -install='ryotunes-bin.install' - -_payload="ryotunes-v${pkgver}-linux-x86_64.tar.zst" -source_x86_64=("${_payload}::https://github.com/ashmitvoid/RYOTUNES/releases/download/v${pkgver}/${_payload}") -sha256sums_x86_64=('2214e492c2402cf1f7ec6026cf94d630af6eafb2e272234876558bc0384493ac') - -package() { - cp -a "$srcdir/usr" "$pkgdir/" -} diff --git a/aur/README.md b/aur/README.md deleted file mode 100644 index e1fa8425..00000000 --- a/aur/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ryotunes-bin AUR packaging - -This directory is the AUR recipe for Ryotunes' precompiled x86_64 Linux release. - -The AUR package downloads the versioned binary payload from the matching GitHub Release and repackages it for pacman. It does **not** build the Tauri/Rust/Svelte application locally. - -Before publishing or updating the AUR package: - -1. Confirm the matching GitHub Release asset exists. -2. Verify its SHA-256 against `PKGBUILD`. -3. Run `makepkg --printsrcinfo > .SRCINFO` on an Arch-based system. -4. Test with `makepkg -si`. -5. Commit `PKGBUILD`, `.SRCINFO`, `ryotunes-bin.install`, and `LICENSE` to the AUR package repository. - -The packaged application remains GPL-3.0-or-later. The AUR packaging files in this directory are offered under 0BSD. diff --git a/aur/ryotunes-bin.install b/aur/ryotunes-bin.install deleted file mode 100644 index 9dcb6731..00000000 --- a/aur/ryotunes-bin.install +++ /dev/null @@ -1,18 +0,0 @@ -post_install() { - /usr/lib/ryotunes-v2.2/activate-replacement || return 1 -} - -post_upgrade() { - /usr/lib/ryotunes-v2.2/activate-replacement || return 1 -} - -pre_remove() { - /usr/lib/ryotunes-v2.2/deactivate-replacement || true -} - -post_remove() { - rm -rf /var/lib/ryotunes-v2.2 - if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database /usr/share/applications >/dev/null 2>&1 || true - fi -} diff --git a/client/App.qml b/client/App.qml index 8269ea6c..01204d83 100644 --- a/client/App.qml +++ b/client/App.qml @@ -32,11 +32,22 @@ Item { 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 + property bool sidebarOpen: false // 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 + // Focus Mode is a separate surface; existing Now Playing remains unchanged. + property bool focusOpen: false + + // Audio Lab is global chrome, never part of a routed page's scroll layout. + // A manual hide persists across navigation until reopened from the top bar. + property bool audioLabOpen: Prefs.audiophileMode + Connections { + target: Prefs + function onAudiophileModeChanged(): void { app.audioLabOpen = Prefs.audiophileMode; } + function onShowAudioLabRequested(): void { app.audioLabOpen = true; } + } // 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. @@ -52,6 +63,11 @@ Item { app.queueOpen = tab === "queue"; app.lyricsOpen = tab === "lyrics"; } + function openFocus() { + if (!Playback.now) return; + app.soundOpen = false; + app.focusOpen = true; + } function npClose() { app.nowPlayingOpen = false; app.queueOpen = false; @@ -102,6 +118,8 @@ Item { ColumnLayout { id: frame anchors.fill: parent + // Hidden, not destroyed, so playback and navigation state survive returning. + visible: !app.focusOpen spacing: 0 // Spec-9 snapshot blur: a modal captures the whole frame behind it once (layer.live: false) @@ -120,6 +138,8 @@ Item { Layout.fillWidth: true sidebarOpen: app.sidebarOpen panelOpen: app.panelShown + audioLabOpen: app.audioLabOpen + onAudioLabClicked: app.audioLabOpen = !app.audioLabOpen onSidebarToggleClicked: app.sidebarOpen = !app.sidebarOpen onPanelToggleClicked: app.panelOpen = !app.panelOpen onSoundClicked: app.soundOpen = true @@ -138,6 +158,12 @@ Item { open: app.sidebarOpen onCollapseRequested: app.sidebarOpen = false } + // Riff-inspired icon navigation, rendered with Ryoku-native controls. + CompactNav { + Layout.fillHeight: true + visible: !app.sidebarOpen + onExpandRequested: app.sidebarOpen = true + } // 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 @@ -159,14 +185,17 @@ Item { 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 } + ProviderGlow { anchors.fill: parent; focusX: 0.82; focusY: 0.1; strength: 0.17 } Item { id: pageClip anchors.fill: parent anchors.leftMargin: Style.pagePad anchors.rightMargin: Style.pagePad - anchors.topMargin: Style.sp(6) + // Make space for the fixed top instrument strip instead of + // overlaying or resizing the Library's scrollable children. + anchors.topMargin: audioLabStrip.visible + ? audioLabStrip.height + Style.sp(5) : Style.sp(6) anchors.bottomMargin: Style.pagePad clip: true @@ -190,6 +219,20 @@ Item { return m[pageStack.page] ? Qt.resolvedUrl("pages/" + m[pageStack.page] + ".qml") : ""; } } + Connections { + target: pageLoader.item + ignoreUnknownSignals: true + function onFocusRequested(): void { app.openFocus(); } + } + // Home places Audio Lab in its own feed, after the hero search + // and before the listening card. Other routes use the top strip. + // Binding only targets HomePage; other pages have no such property. + Binding { + target: pageStack.page === "home" ? pageLoader.item : null + property: "audioLabEnabled" + value: Prefs.audiophileMode && app.audioLabOpen + && !app.focusOpen && !app.modalOpen + } Loader { anchors.fill: parent active: pageLoader.status !== Loader.Ready @@ -229,11 +272,38 @@ Item { NowPlaying { anchors.fill: parent + // The stage shares the content region below the Audio Lab. + anchors.topMargin: audioLabStrip.visible + ? audioLabStrip.height + Style.sp(5) : 0 nowPlayingOpen: app.nowPlayingOpen queueOpen: app.queueOpen lyricsOpen: app.lyricsOpen onTabRequested: (tab) => app.npOpenTab(tab) onCloseRequested: app.npClose() + onFocusRequested: app.openFocus() + } + + // Audiophile Mode: the original wide, low-profile spectrum layout, + // now fixed to the top of the shared content surface on every route. + // No floating overlay, bottom docking, or debug metadata heading. + // Hiding the strip releases its capture claim and restores page height. + AudioLabWorkbench { + id: audioLabStrip + visible: Prefs.audiophileMode && app.audioLabOpen + && !app.focusOpen && !app.modalOpen + && pageStack.page !== "home" + active: visible + z: 10 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: Style.pagePad + anchors.rightMargin: Style.pagePad + anchors.topMargin: Style.sp(2) + // Approx. 192-200px on a full-size window; adapt gracefully + // to shorter windows without swallowing the page beneath. + height: Math.min(Style.sp(50), + Math.max(Style.sp(30), parent.height * 0.30)) } } @@ -290,6 +360,25 @@ Item { } } + // Ryotunes Focus Mode (Riff-inspired interaction, Ryotunes-native surface). + // A separate listening surface, created only when requested; the daemon and + // existing Now Playing state are preserved when the user exits. + Loader { + id: focusLoader + anchors.fill: parent + active: app.focusOpen + source: "chrome/FocusMode.qml" + } + Connections { + target: focusLoader.item + ignoreUnknownSignals: true + function onCloseRequested(): void { app.focusOpen = false; } + } + Connections { + target: Playback + function onNowChanged(): void { if (!Playback.now) app.focusOpen = false; } + } + // ── foreground layer ──────────────────────────────────────────────────────────────── Toast { } @@ -321,9 +410,10 @@ Item { Shortcut { sequences: ["Escape"] context: Qt.WindowShortcut - enabled: palette.open || app.nowPlayingOpen + enabled: palette.open || app.focusOpen || app.nowPlayingOpen onActivated: { if (palette.open) palette.open = false; + else if (app.focusOpen) app.focusOpen = false; else app.npClose(); } } diff --git a/client/Daemon.qml b/client/Daemon.qml index 1bd2bb40..9ea945f0 100644 --- a/client/Daemon.qml +++ b/client/Daemon.qml @@ -10,6 +10,8 @@ import Quickshell.Io Singleton { id: root + property bool quitting: false + 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 @@ -25,6 +27,83 @@ Singleton { // the first subscribe and again after every reconnect so Playback resynchronises each time. signal snapshot(var data) + // Soloist owns playback while Spotify is the selected catalogue. + // The native MPV player remains untouched for every other provider. + property bool soloistMode: false + property bool soloistPaused: true + property bool soloistShuffle: false + property var soloistQueueItems: [] + + function soloistItemId(item) { + if (!item) return ""; + return String(item.video_id || item.videoId || item.uri || item.id || ""); + } + + function soloistRoute(method, params) { + if (!root.soloistMode) return null; + var p = params || {}; + var action = ""; + var args = {}; + var uri = ""; + + if (method === "play") { + uri = root.soloistItemId(p.item); + if (!uri.startsWith("spotify:track:")) + return { error: "Spotify Soloist can play Spotify tracks only" }; + action = "play"; + args = { uri: uri }; + } else if (method === "play_playlist") { + // Prefer native Spotify playlist context, if the caller supplied one. + uri = String(p.spotifyUri || ""); + if (!uri.startsWith("spotify:playlist:") && !uri.startsWith("spotify:album:")) { + var rows = p.items || []; + var idx = p.start === undefined || p.start === null ? 0 : p.start; + uri = root.soloistItemId(rows[idx] || rows[0]); + } + if (!uri.startsWith("spotify:")) + return { error: "No Spotify tracks to play" }; + action = "play"; + args = { uri: uri }; + } else if (method === "play_index") { + uri = root.soloistItemId(root.soloistQueueItems[p.index]); + if (!uri.startsWith("spotify:track:")) + return { error: "Spotify queue item unavailable" }; + action = "play"; + args = { uri: uri }; + } else if (method === "toggle_pause") { + action = root.soloistPaused ? "play" : "pause"; + } else if (method === "next_track") { + action = "skip_next"; + } else if (method === "prev_track") { + action = "skip_prev"; + } else if (method === "seek") { + action = "seek"; + args = { positionMs: Math.max(0, Math.round(Number(p.position) * 1000)) }; + } else if (method === "set_volume") { + action = "volume"; + args = { volume: Math.max(0, Math.min(100, Math.round(Number(p.volume)))) }; + } else if (method === "toggle_shuffle") { + action = "shuffle"; + args = { enabled: !root.soloistShuffle }; + root.soloistShuffle = args.enabled; + } else if (method === "set_repeat") { + action = "repeat"; + args = { mode: p.mode === "one" ? "track" : p.mode === "all" ? "context" : "off" }; + } else if (method === "add_to_queue" || method === "play_next") { + var items = p.items || []; + uri = root.soloistItemId(items[0]); + if (!uri.startsWith("spotify:track:")) + return { error: "Soloist queue accepts Spotify tracks only" }; + action = "add_to_queue"; + args = { uri: uri }; + } else if (["move_in_queue", "remove_from_queue", "clear_queued", "set_stop_after_current"].indexOf(method) !== -1) { + return { error: "This queue operation is not supported by Spotify Soloist" }; + } else { + return null; + } + return { method: "soloist_control", params: { action: action, args: args } }; + } + property int nextId: 1 property var pending: ({}) // Whether a subscription has been asked for; drives the re-subscribe on reconnect. @@ -32,16 +111,24 @@ Singleton { 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(); + if (root.quitting || root.connected) + return; + + if (root.sock) + root.sock.destroy(); + root.sock = socketComponent.createObject(root); root.sock.connected = true; } Component.onCompleted: root.reconnect() function call(method, params) { + var routed = root.soloistRoute(method, params); + if (routed) { + if (routed.error) return Promise.reject({code: "soloist_unsupported", message: routed.error}); + method = routed.method; + params = routed.params; + } return new Promise((resolve, reject) => { if (!root.connected) { // The daemon never sees this failure, so it can only be recorded here: a user @@ -108,8 +195,9 @@ Singleton { Timer { id: retry interval: 2000 - running: !root.connected + running: !root.quitting && !root.connected repeat: true + onTriggered: root.reconnect() } } diff --git a/client/Playback.qml b/client/Playback.qml index d30ccd6f..b1d56697 100644 --- a/client/Playback.qml +++ b/client/Playback.qml @@ -73,10 +73,193 @@ Singleton { // A surface (player-bar tools, track menu) asking the App to open the Sound dialog. signal soundRequested() + // --- Spotify Soloist: use the same player bar, not a second interface --------------------- + // The Rust daemon forwards native Soloist updates as "soloist-event". A sparse snapshot + // poll repairs late attachment and daemon reconnects without depending on event timing. + property bool soloistLoading: false + property string soloistError: "" + + function soloistTrack(n) { + if (!n || !n.videoId) return; + var seconds = Math.max(0, Math.round(Number(n.durationMs || 0) / 1000)); + var minutes = Math.floor(seconds / 60); + var rem = (seconds % 60).toString().padStart(2, "0"); + root.now = { + videoId: n.videoId, + title: n.title || "Unknown track", + artists: n.artists || "", + artist: n.artists || "", + album: n.album || "", + thumbnail: n.thumbnail || "", + duration: minutes + ":" + rem, + rating: "indifferent" + }; + root.duration = Math.max(0, Number(n.durationMs || 0) / 1000); + root.position = PB.clampPosition(root.duration, Number(n.positionMs || 0) / 1000); + root.paused = !!n.paused; + root.volume = Number(n.volume === undefined ? root.volume : n.volume); + Daemon.soloistPaused = root.paused; + root.pendingVideoId = null; + root.lastError = null; + root.soloistError = ""; + root.soloistEnsureQueue(); + } + + function soloistEnsureQueue() { + if (!root.now) return; + var q = root.queue || {}; + var items = q.items || []; + if (!items.length || items[0].video_id !== root.now.videoId) { + root.queue = { + items: [{ video_id: root.now.videoId, title: root.now.title, + artists: root.now.artists, thumbnail: root.now.thumbnail, + duration: root.now.duration }], + currentIndex: 0, shuffle: Daemon.soloistShuffle, + repeat: q.repeat || "off", sourceName: "Spotify" + }; + } + Daemon.soloistQueueItems = root.queue.items; + } + + function soloistReadQueue(source) { + if (!source || root.provider !== "spotify") return; + var rows = source.queue || source.items || source.tracks || source.next_tracks || []; + if (!Array.isArray(rows)) rows = rows.items || rows.tracks || []; + var current = root.now ? [{ video_id: root.now.videoId, + title: root.now.title, artists: root.now.artists, + thumbnail: root.now.thumbnail, duration: root.now.duration }] : []; + for (var i = 0; i < rows.length; i++) { + var item = rows[i].item || rows[i].track || rows[i]; + var id = item.uri || item.video_id || item.videoId || ""; + if (!String(id).startsWith("spotify:track:")) continue; + if (current.length && current[0].video_id === id) continue; + var dec = item.decorations || {}; + var creators = dec.creators || []; + var artists = creators.map(c => (c.entity && c.entity.decorations + && c.entity.decorations.identity || {}).name || "").filter(Boolean).join(", "); + var covers = (dec.visual_identity && dec.visual_identity.cover) || []; + var cover = covers.filter(c => c.size === "large")[0] || covers[covers.length - 1]; + var secs = Math.round(((dec.playback || {}).duration_ms || 0) / 1000); + current.push({ video_id: id, + title: (dec.identity || {}).name || item.title || "Unknown track", + artists: artists || item.artists || "", + thumbnail: (cover && cover.url) || item.thumbnail || "", + duration: Math.floor(secs / 60) + ":" + (secs % 60).toString().padStart(2, "0") }); + } + var old = root.queue || {}; + root.queue = { items: current, currentIndex: 0, shuffle: Daemon.soloistShuffle, + repeat: old.repeat || "off", sourceName: "Spotify" }; + Daemon.soloistQueueItems = current; + } + + function soloistEvent(event) { + if (!event || root.provider !== "spotify") return; + var src = event.source || {}; + if (event.kind === "playback_state" && event.now) { + root.soloistTrack(event.now); + if (src.options) root.soloistOptions(src.options); + } else if (event.kind === "position_sync" && src.position) { + root.position = PB.clampPosition(root.duration, Number(src.position.position_ms || 0) / 1000); + } else if (event.kind === "playback_changed") { + root.paused = src.status !== "playing"; + Daemon.soloistPaused = root.paused; + } else if (event.kind === "volume_changed") { + if (!root.volDrag && typeof src.volume === "number") root.volume = src.volume; + } else if (event.kind === "queue_changed") { + root.soloistReadQueue(src); + } else if (event.kind === "options_changed") { + root.soloistOptions(src.options || src); + } else if (event.kind === "track_changed") { + root.soloistRefresh(); + } + } + + function soloistOptions(o) { + if (!o) return; + var q = root.queue || {}; + if (o.shuffle !== undefined) Daemon.soloistShuffle = !!o.shuffle; + var repeat = o.repeat === "track" || o.repeat === "one" ? "one" + : o.repeat === "context" || o.repeat === "all" ? "all" : "off"; + root.queue = Object.assign({}, q, { shuffle: Daemon.soloistShuffle, repeat: repeat }); + } + + function soloistRefresh() { + if (root.provider !== "spotify" || !Daemon.connected || root.soloistLoading) return; + root.soloistLoading = true; + Daemon.call("soloist_now").then(result => { + if (root.provider !== "spotify") return; + root.soloistTrack(result.now); + if (result.source && result.source.options) root.soloistOptions(result.source.options); + }).catch(e => { + var message = (e && e.message) || "Start Spotify Soloist to play music"; + if (root.soloistError !== message) root.toast(message, "error"); + root.soloistError = message; + }).finally(() => { root.soloistLoading = false; }); + } + + function soloistRestoreMpv() { + if (!Daemon.connected) return; + Daemon.call("get_playback").then(pb => { + if (root.provider === "spotify" || !pb) return; + root.now = pb.now; + root.paused = pb.paused; + root.volume = pb.volume; + root.duration = pb.duration; + root.position = PB.clampPosition(pb.duration, pb.position); + }).catch(() => {}); + Daemon.call("get_queue").then(q => { + if (root.provider !== "spotify") root.queue = q; + }).catch(() => {}); + } + + onProviderChanged: { + var enteringSpotify = root.provider === "spotify"; + // Only one audio engine should remain audible after a provider switch. + if (enteringSpotify && !Daemon.soloistMode && root.now && !root.paused) + Daemon.call("toggle_pause").catch(() => {}); + if (!enteringSpotify && Daemon.soloistMode && !Daemon.soloistPaused) + Daemon.call("soloist_control", { action: "pause", args: {} }).catch(() => {}); + Daemon.soloistMode = enteringSpotify; + if (Daemon.soloistMode) { + root.now = null; + root.queue = { items: [], currentIndex: 0, shuffle: false, repeat: "off" }; + root.soloistRefresh(); + Daemon.call("soloist_queue", { limit: 20 }) + .then(q => root.soloistReadQueue(q)).catch(() => {}); + } else { + root.soloistRestoreMpv(); + } + } + + Timer { + interval: 250 + repeat: true + running: root.provider === "spotify" && !root.paused && root.duration > 0 + onTriggered: root.position = PB.clampPosition(root.duration, root.position + 0.25) + } + Timer { + interval: 8000 + repeat: true + running: root.provider === "spotify" && Daemon.connected + onTriggered: { + root.soloistRefresh(); + Daemon.call("soloist_queue", { limit: 20 }) + .then(q => root.soloistReadQueue(q)).catch(() => {}); + } + } + // --- events + opening snapshot ----------------------------------------------------------- Connections { target: Daemon function onEvent(name, data) { + if (name === "soloist-event") { + root.soloistEvent(data); + return; + } + // Prevent MPV state echoes from overwriting Soloist's visible player. + if (root.provider === "spotify" && ["now-playing", "position", "duration", + "playback-state", "queue-changed", "queue-index", "volume", "rating"] + .indexOf(name) !== -1) return; var fx = PB.applyEvent(root, name, data); if (fx) { if (fx.toast) root.toast(fx.toast, fx.kind); @@ -91,9 +274,10 @@ Singleton { // (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; + if (snap.provider) root.provider = snap.provider; + if (snap.queue && root.provider !== "spotify") root.queue = snap.queue; var pb = snap.playback; - if (pb) { + if (pb && root.provider !== "spotify") { 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; @@ -107,7 +291,6 @@ Singleton { 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 }; @@ -155,7 +338,7 @@ Singleton { 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 }); + return Daemon.call("set_audio_fx", { speed: fx.speed, semitones: fx.semitones, reverb: fx.reverb, bass: fx.bass, width: fx.width, eq: fx.eq }); } // 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. diff --git a/client/Prefs.qml b/client/Prefs.qml index 411b7852..b2e4f78b 100644 --- a/client/Prefs.qml +++ b/client/Prefs.qml @@ -8,10 +8,12 @@ import Quickshell.Io // shell.json pattern). Absent or malformed reads as the defaults below. Singleton { id: root + signal showAudioLabRequested() 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" + property alias audiophileMode: adapter.audiophileMode // Opt-in advanced UI; does not change playback EQ // The mini widget's offset from the screen's bottom-right corner, logical px. property alias miniRight: adapter.miniRight property alias miniBottom: adapter.miniBottom @@ -31,6 +33,7 @@ Singleton { property string skin: "system" property string decor: "skin" property string themeMode: "system" + property bool audiophileMode: false property int miniRight: 24 property int miniBottom: 24 } diff --git a/client/Style.qml b/client/Style.qml index 440061cf..e08126b4 100644 --- a/client/Style.qml +++ b/client/Style.qml @@ -20,7 +20,9 @@ Singleton { // 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 + // One restrained corner shape across controls, cards and Audiophile instruments. + // Preserve Style.radiusCard as an alias for existing components/skin consumers. + readonly property int radiusCard: root.radius // matches Btn / IconButton 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 diff --git a/client/chrome/CompactNav.qml b/client/chrome/CompactNav.qml new file mode 100644 index 00000000..2a6e8cbb --- /dev/null +++ b/client/chrome/CompactNav.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// Icon-only companion to the existing full Sidebar. Fast, accessible navigation +// while the labelled rail is collapsed, without changing Router or playlists. +Rectangle { + id: root + signal expandRequested() + + implicitWidth: Style.sp(14) + color: Tokens.paper + + Hairline { + anchors.right: parent.right + width: 1 + height: parent.height + } + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: Style.sp(3) + anchors.bottomMargin: Style.sp(3) + spacing: Style.sp(2) + + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "sidebar" + iconSize: Style.fs.lg + diameter: Style.sp(9) + tip: "Expand navigation" + tipBelow: true + onClicked: root.expandRequested() + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: Style.sp(3) + Layout.rightMargin: Style.sp(3) + Layout.topMargin: Style.sp(2) + Layout.bottomMargin: Style.sp(2) + Layout.preferredHeight: 1 + color: Tokens.lineSoft + } + + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "home" + tip: "Home" + active: Router.current && Router.current.page === "home" + onClicked: Router.push("home") + } + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "search" + tip: "Search" + active: Router.current && Router.current.page === "search" + onClicked: Router.push("search") + } + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "radio" + tip: "Radio" + active: Router.current && Router.current.page === "radio" + onClicked: Router.push("radio") + } + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "library" + tip: "Library" + active: Router.current && Router.current.page === "library" + onClicked: Router.push("library", { tab: "songs" }) + } + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "download" + tip: "Downloads" + active: Router.current && Router.current.page === "downloads" + onClicked: Router.push("downloads") + } + + Item { Layout.fillHeight: true } + + IconButton { + Layout.alignment: Qt.AlignHCenter + icon: "settings" + tip: "Settings" + active: Router.current && Router.current.page === "settings" + onClicked: Router.push("settings") + } + } +} diff --git a/client/chrome/FocusMode.qml b/client/chrome/FocusMode.qml new file mode 100644 index 00000000..ef1d8acf --- /dev/null +++ b/client/chrome/FocusMode.qml @@ -0,0 +1,275 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" +import "../components" + +// Ryotunes Focus Mode v2: a native, artwork-led listening space. +// Riff-inspired immersion; Ryotunes layout, tokens, playback and lyrics remain authoritative. +// Instantiated only while the user requests it. There is no secondary audio engine. +Item { + id: root + focus: true + clip: true + + signal closeRequested() + + readonly property var now: Playback.now + readonly property int artSize: Math.max(Style.sp(40), Math.min( + Style.sp(110), Math.round(width * 0.34), Math.round(height * 0.48))) + readonly property bool soundcloud: !!root.now && String(root.now.videoId).startsWith("sc:") + property string coverFlash: "" + + Timer { id: flashTimer; interval: 220; onTriggered: root.coverFlash = "" } + + function togglePlayback() { + root.coverFlash = Playback.paused ? "play" : "pause"; + flashTimer.restart(); + Playback.togglePause(); + } + + Rectangle { anchors.fill: parent; color: Tokens.paper } + + // Reuse the app's existing blurred-cover wash: static between track changes. + Backdrop { + anchors.fill: parent + strength: 0.3 + focusX: 0.28 + focusY: 0.36 + } + Rectangle { + anchors.fill: parent + color: Qt.rgba(Tokens.paper.r, Tokens.paper.g, Tokens.paper.b, + Style.paperDark ? 0.28 : 0.5) + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Style.sp(10) + anchors.rightMargin: Style.sp(10) + anchors.topMargin: Style.sp(6) + anchors.bottomMargin: Style.sp(7) + spacing: Style.sp(5) + + // The normal title bar and sidebar are hidden by App while Focus Mode is open. + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + + Text { + text: "力 RYOTUNES" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.letterSpacing: Style.trackMicro + } + Item { Layout.fillWidth: true } + IconButton { + icon: "close" + iconSize: Style.fs.lg + diameter: Style.sp(9) + outlined: true + tip: "Leave Focus Mode" + tipBelow: true + onClicked: root.closeRequested() + } + } + + Hairline { Layout.fillWidth: true } + + RowLayout { + id: listeningArea + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Style.sp(12) + + // Cover and track identity. Keep the original artwork shape; no Riff morph overlays. + ColumnLayout { + Layout.preferredWidth: Math.max(root.artSize, Math.round(root.width * 0.39)) + Layout.fillHeight: true + spacing: Style.sp(3) + + // The stage should start near the editorial header, not float midway down + // an otherwise empty full-height column. Leave the remaining room below. + Item { + Layout.preferredHeight: Math.max(Style.sp(2), + Math.min(Style.sp(16), Math.round(root.height * 0.05))) + } + + Item { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: root.artSize + Layout.preferredHeight: root.artSize + + Artwork { + anchors.fill: parent + px: root.artSize + cornerRadius: Style.radiusCard + url: (root.now && root.now.thumbnail) ? root.now.thumbnail : "" + } + Rectangle { + anchors.fill: parent + radius: Style.radiusCard + color: "#66000000" + opacity: coverArea.containsMouse ? 1 : 0 + Behavior on opacity { + enabled: !Tokens.reduceMotion + NumberAnimation { duration: Style.motion.snap } + } + } + Icon { + anchors.centerIn: parent + name: root.coverFlash !== "" ? root.coverFlash + : (Playback.paused ? "play" : "pause") + size: Style.fs.title + color: "#ffffff" + visible: root.coverFlash !== "" || coverArea.containsMouse + } + MouseArea { + id: coverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.togglePlayback() + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + Layout.maximumWidth: Math.max(root.artSize, Style.sp(65)) + spacing: Style.sp(1) + + Text { + Layout.fillWidth: true + text: (root.now && root.now.title) ? root.now.title : "Nothing playing" + textFormat: Text.PlainText + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.title + maximumLineCount: 2 + wrapMode: Text.WordWrap + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + Text { + Layout.fillWidth: true + text: (root.now && root.now.artists) ? root.now.artists : "" + textFormat: Text.PlainText + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: root.soundcloud ? "SOUNDCLOUD" : Playback.provider.toUpperCase() + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + horizontalAlignment: Text.AlignHCenter + } + } + + Item { Layout.fillHeight: true } + } + + // Reuse the existing lyrics component: word timings, seek-to-line, source fallback, + // reduced motion and the visibility-gated timer all remain in one implementation. + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Style.sp(3) + + // Focus typography is an opt-in display treatment. The lyric data, + // scrolling, network requests and timing remain owned by LyricsPanel. + LyricsPanel { + id: lyricsView + focusMode: true + Layout.fillWidth: true + Layout.fillHeight: true + } + } + } + + Hairline { Layout.fillWidth: true } + + // One transport. The daemon remains the only authority for playback and volume. + ColumnLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Text { + text: Style.fmtTime(Playback.shownPosition) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + Slider { + Layout.fillWidth: true + from: 0 + to: Math.max(1, Playback.duration) + value: Playback.shownPosition + trackColor: Tokens.lineStrong + fillColor: Style.accent + onMoved: (v) => Playback.seekDrag = v + onCommitted: (v) => { + Playback.seekDrag = NaN; + Playback.seek(v).catch((e) => Playback.toast( + (e && e.message) ? e.message : "Seek failed", "error")); + } + } + Text { + text: Style.fmtTime(Playback.duration) + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Style.sp(4) + IconButton { + icon: "shuffle" + tip: "Shuffle" + active: !!(Playback.queue && Playback.queue.shuffle) + onClicked: Playback.toggleShuffle() + } + IconButton { + icon: "previous" + iconSize: Style.fs.lg + diameter: Style.sp(10) + tip: "Previous track" + onClicked: Playback.prev() + } + IconButton { + icon: Playback.paused ? "play" : "pause" + iconSize: Style.fs.xl + diameter: Style.sp(13) + primary: true + tip: Playback.paused ? "Play" : "Pause" + onClicked: root.togglePlayback() + } + IconButton { + icon: "next" + iconSize: Style.fs.lg + diameter: Style.sp(10) + tip: "Next track" + onClicked: Playback.next() + } + IconButton { + icon: (Playback.queue && Playback.queue.repeat === "one") ? "repeat-one" : "repeat" + tip: "Repeat" + active: !!(Playback.queue && Playback.queue.repeat && Playback.queue.repeat !== "off") + onClicked: Playback.cycleRepeat() + } + } + } + } +} diff --git a/client/chrome/LyricsPanel.qml b/client/chrome/LyricsPanel.qml index ac39b1a1..aa92b647 100644 --- a/client/chrome/LyricsPanel.qml +++ b/client/chrome/LyricsPanel.qml @@ -18,6 +18,8 @@ Item { id: root property bool compact: false + // Display-only typography treatment for the full-window listening stage. + property bool focusMode: false // --- fetched state ---------------------------------------------------------------------- property var lyrics: null @@ -257,7 +259,9 @@ Item { 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) + : root.focusMode + ? (lineWrap.isActive ? Style.fs.title : Style.fs.xl) + : (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. @@ -529,7 +533,7 @@ Item { Layout.topMargin: Style.sp(2) Layout.bottomMargin: Style.sp(2) spacing: Style.sp(2) - visible: !root.compact && !!root.lyrics && !root.loading + visible: !root.compact && !root.focusMode && !!root.lyrics && !root.loading Text { Layout.fillWidth: true text: { diff --git a/client/chrome/NowPlaying.qml b/client/chrome/NowPlaying.qml index d6265b8a..50c52545 100644 --- a/client/chrome/NowPlaying.qml +++ b/client/chrome/NowPlaying.qml @@ -26,6 +26,7 @@ Item { property bool lyricsOpen: false signal tabRequested(string tab) signal closeRequested() + signal focusRequested() readonly property bool open: root.nowPlayingOpen || root.queueOpen || root.lyricsOpen @@ -97,6 +98,35 @@ Item { font.pixelSize: Style.fs.micro font.letterSpacing: Style.trackMicro } + // First-party Ryotunes entry point to the Riff-inspired immersive stage. + Rectangle { + id: focusPill + implicitWidth: focusRow.implicitWidth + Style.sp(6) + implicitHeight: Style.sp(9) + radius: Style.radius + color: focusHover.hovered ? Tokens.tint10 : Tokens.paperLift + border.width: 1 + border.color: focusHover.hovered ? Tokens.lineStrong : Tokens.line + RowLayout { + id: focusRow + anchors.centerIn: parent + spacing: Style.sp(1.5) + Icon { name: "expand"; size: Style.fs.sm; color: Tokens.inkDim } + Text { + text: "Focus" + color: Tokens.inkDim + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + font.weight: Font.Medium + } + } + HoverHandler { id: focusHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.focusRequested() + } + } Rectangle { id: collapsePill implicitWidth: collapseRow.implicitWidth + Style.sp(6) diff --git a/client/chrome/Sidebar.qml b/client/chrome/Sidebar.qml index 4cbd1474..81340ef3 100644 --- a/client/chrome/Sidebar.qml +++ b/client/chrome/Sidebar.qml @@ -93,26 +93,15 @@ Rectangle { Accessible.onPressAction: nr.activated() Keys.onReturnPressed: nr.activated() Keys.onSpacePressed: nr.activated() - border.width: activeFocus ? 2 : 0 - border.color: Tokens.ink + border.width: activeFocus ? 2 : nr.current ? 1 : 0 + border.color: activeFocus ? Tokens.ink : Tokens.lineSoft Layout.fillWidth: true implicitHeight: Style.sp(10) radius: Style.radius - color: nr.current ? Tokens.paperLift : navHover.hovered ? Tokens.tint5 : "transparent" + color: nr.current ? Tokens.tint10 : 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) @@ -121,7 +110,7 @@ Rectangle { Icon { name: nr.icon size: Style.fs.lg - color: nr.current ? Tokens.ink : Tokens.inkMuted + color: nr.current ? Style.accent : Tokens.inkMuted } Text { Layout.fillWidth: true @@ -167,40 +156,24 @@ Rectangle { } } - // A section register: number, tracked mono micro label, a hairline to the right and (rich) seal. + // Small navigation headings establish hierarchy without numbering or ornamental rules. component SectionLabel: RowLayout { id: sl - property string num: "" + property string num: "" // Kept for existing call-site compatibility. property string label: "" - property string seal: "" + property string seal: "" // Kept for existing call-site compatibility. Layout.fillWidth: true Layout.leftMargin: Style.sp(3) Layout.rightMargin: Style.sp(3) - Layout.topMargin: Style.sp(3) + Layout.topMargin: Style.sp(4) 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 + color: Tokens.inkFaint 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. @@ -328,7 +301,7 @@ Rectangle { Rectangle { Layout.fillWidth: true Layout.bottomMargin: Style.sp(2) - implicitHeight: Style.sp(14) + implicitHeight: Style.sp(12) radius: Style.radiusCard color: "transparent" border.width: 1 @@ -369,15 +342,6 @@ Rectangle { 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" @@ -429,10 +393,6 @@ Rectangle { 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 { @@ -456,37 +416,14 @@ Rectangle { } } } - } - } + // 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") } - // --- 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 index 0dc4fca9..d3054954 100644 --- a/client/chrome/SoundDialog.qml +++ b/client/chrome/SoundDialog.qml @@ -22,7 +22,9 @@ Item { // 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 }) + property var fx: ({ speed: 1, semitones: 0, reverb: 0, bass: 0, width: 0, eq: ({ enabled: false, bands: [] }) }) + property bool equalizerOpen: false + property int selectedEqBand: 10 // 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 @@ -40,7 +42,7 @@ Item { Component.onCompleted: { var a = Playback.audioFx; - root.fx = { speed: a.speed, semitones: a.semitones, reverb: a.reverb, bass: a.bass, width: a.width }; + root.fx = { speed: a.speed, semitones: a.semitones, reverb: a.reverb, bass: a.bass, width: a.width, eq: a.eq || root.defaultEq() }; // 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. @@ -55,6 +57,34 @@ Item { Keys.onEscapePressed: (e) => { root.closeRequested(); e.accepted = true; } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + function defaultEq() { + const hz = [32, 45, 64, 90, 125, 180, 250, 350, 500, 700, + 1000, 1400, 2000, 2800, 4000, 5600, 8000, 11000, 15000, 19000]; + return { enabled: false, bands: hz.map(frequency => ({ frequency: frequency, gain: 0, q: 1 })) }; + } + function activeBand() { + const bands = root.fx.eq && root.fx.eq.bands ? root.fx.eq.bands : []; + return bands[root.selectedEqBand] || { frequency: 1000, gain: 0, q: 1 }; + } + function setEqEnabled(enabled) { + root.patch({ eq: Object.assign({}, root.fx.eq, { enabled: enabled }) }); + } + function setEqBand(index, changes) { + const bands = root.fx.eq.bands.map((band, i) => i === index + ? Object.assign({}, band, changes) : band); + root.patch({ eq: Object.assign({}, root.fx.eq, { bands: bands }) }); + } + function setEqGain(index, value) { + root.setEqBand(index, { gain: root.clamp(Math.round(value * 10) / 10, -12, 12) }); + } + function setEqQ(index, value) { + root.setEqBand(index, { q: root.clamp(Math.round(value * 10) / 10, 0.3, 10) }); + } + function setEqFrequency(index, position) { + const frequency = 20 * Math.pow(1000, root.clamp(position, 0, 1)); + root.setEqBand(index, { frequency: Math.round(frequency) }); + } + function resetEq() { root.patch({ eq: root.defaultEq() }); } // 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(); } @@ -63,7 +93,7 @@ Item { 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 applyPreset(p) { root.patch(p); } 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; @@ -111,7 +141,9 @@ Item { Rectangle { id: card anchors.centerIn: parent - width: Style.sp(120) + width: root.equalizerOpen + ? Math.min(Style.sp(236), root.width - Style.sp(12)) + : Style.sp(120) implicitHeight: col.implicitHeight + Style.sp(12) height: implicitHeight radius: Style.radiusCard @@ -142,13 +174,6 @@ Item { 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 @@ -156,6 +181,11 @@ Item { font.pixelSize: Style.fs.xl } } + Chip { + text: root.equalizerOpen ? "EFFECTS" : "20-BAND EQ" + active: root.equalizerOpen + onClicked: root.equalizerOpen = !root.equalizerOpen + } IconButton { Layout.alignment: Qt.AlignTop icon: "close" @@ -166,6 +196,7 @@ Item { // sliders ColumnLayout { + visible: !root.equalizerOpen Layout.fillWidth: true spacing: Style.sp(4) FxRow { @@ -200,10 +231,285 @@ Item { } } + ColumnLayout { + visible: root.equalizerOpen + Layout.fillWidth: true + spacing: Style.sp(3) + + // The equalizer is a listening surface, not a scrolling form. + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + Item { Layout.fillWidth: true } + Chip { + text: root.fx.eq.enabled ? "ON" : "BYPASS" + active: root.fx.eq.enabled + onClicked: root.setEqEnabled(!root.fx.eq.enabled) + } + Chip { + text: "FLAT" + onClicked: root.resetEq() + } + } + + // One response drawing; it repaints only when the EQ data changes. + Item { + Layout.fillWidth: true + Layout.preferredHeight: Math.max(Style.sp(24), + Math.min(Style.sp(38), root.height * 0.13)) + + Rectangle { + anchors.fill: parent + color: Tokens.paperLift + radius: Style.radius + } + + Canvas { + id: eqGraph + anchors.fill: parent + anchors.margins: Style.sp(3) + onPaint: { + const ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + const bands = root.fx.eq && root.fx.eq.bands ? root.fx.eq.bands : []; + const dbAt = function(hz) { + let db = 0; + for (const band of bands) { + const delta = Math.log(hz / Math.max(1, band.frequency)) / Math.LN2; + const spread = 1 / Math.max(0.3, band.q); + db += band.gain * Math.exp(-0.5 * Math.pow(delta / spread, 2)); + } + return Math.max(-18, Math.min(18, db)); + }; + const yAt = function(db) { + return height / 2 - db * height / 42; + }; + + // Quiet grid, with a slightly stronger zero-gain axis. + ctx.lineWidth = 1; + for (const db of [-12, -6, 0, 6, 12]) { + const y = yAt(db); + ctx.globalAlpha = db === 0 ? 0.38 : 0.16; + ctx.strokeStyle = Tokens.inkMuted; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + for (const hz of [100, 1000, 10000]) { + const x = width * Math.log(hz / 20) / Math.log(1000); + ctx.globalAlpha = 0.16; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + // Preview of the combined response. The player is authoritative. + ctx.globalAlpha = root.fx.eq.enabled ? 0.95 : 0.28; + ctx.strokeStyle = Style.accent; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let x = 0; x <= width; x += 2) { + const hz = 20 * Math.pow(1000, x / Math.max(1, width)); + const y = yAt(dbAt(hz)); + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineTo(width, yAt(dbAt(20000))); + ctx.stroke(); + + // Selected band's position, so frequency edits have context. + const selected = root.activeBand(); + const sx = Math.max(0, Math.min(width, + width * Math.log(Math.max(20, selected.frequency) / 20) / Math.log(1000))); + const sy = yAt(dbAt(Math.max(20, selected.frequency))); + ctx.globalAlpha = root.fx.eq.enabled ? 0.8 : 0.3; + ctx.strokeStyle = Tokens.inkMuted; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(sx, 0); + ctx.lineTo(sx, height); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = Style.accent; + ctx.beginPath(); + ctx.arc(sx, sy, Style.sp(1.1), 0, Math.PI * 2); + ctx.fill(); + } + Connections { + target: root + function onFxChanged() { eqGraph.requestPaint(); } + function onSelectedEqBandChanged() { eqGraph.requestPaint(); } + } + } + } + + // Every band stays instantiated during dragging. No Flickable stealing + // pointer gestures and no delegate recreation on gain updates. + Row { + id: faderRow + Layout.fillWidth: true + Layout.preferredHeight: Math.max(Style.sp(34), + Math.min(Style.sp(47), root.height * 0.19)) + spacing: 0 + + Repeater { + model: 20 + delegate: Item { + id: fader + required property int index + readonly property var band: root.fx.eq.bands[fader.index] + readonly property bool selected: root.selectedEqBand === fader.index + readonly property real gain: fader.band ? fader.band.gain : 0 + width: faderRow.width / 20 + height: faderRow.height + + function gainAt(y) { + if (gainTrack.height <= 0) return; + const fraction = Math.max(0, Math.min(1, y / gainTrack.height)); + root.setEqGain(fader.index, 12 - 24 * fraction); + } + + Rectangle { + anchors.fill: parent + anchors.margins: 1 + radius: Style.radius + color: Tokens.paperLift + opacity: fader.selected ? 1 : 0 + } + Text { + anchors.top: parent.top + anchors.topMargin: Style.sp(1) + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: (fader.gain > 0 ? "+" : "") + + Number(fader.gain).toFixed(1) + color: fader.selected ? Tokens.ink : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + visible: fader.selected || Math.abs(fader.gain) > 0.05 + } + Item { + id: gainTrack + anchors.top: parent.top + anchors.topMargin: Style.sp(7) + anchors.bottom: freqLabel.top + anchors.bottomMargin: Style.sp(3) + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(Style.sp(5), fader.width - Style.sp(1)) + + Rectangle { + anchors.centerIn: parent + width: 2 + height: parent.height + radius: 1 + color: Tokens.lineStrong + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: Math.min(parent.height / 2, + (12 - fader.gain) / 24 * parent.height) + width: 3 + height: Math.max(0.5, Math.abs(parent.height / 2 - + (12 - fader.gain) / 24 * parent.height)) + radius: 1 + color: Style.accent + opacity: root.fx.eq.enabled ? 1 : 0.35 + } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: Math.max(0, Math.min(parent.height - height, + (12 - fader.gain) / 24 * parent.height - height / 2)) + width: Math.max(Style.sp(3), fader.width - Style.sp(2)) + height: Style.sp(1.3) + radius: height / 2 + color: fader.selected ? Style.accent : Tokens.inkMuted + } + MouseArea { + id: gainMouse + anchors.fill: parent + preventStealing: true + cursorShape: Qt.SizeVerCursor + onPressed: (mouse) => { + root.selectedEqBand = fader.index; + fader.gainAt(mouse.y); + } + onPositionChanged: (mouse) => { + if (gainMouse.pressed) fader.gainAt(mouse.y); + } + onReleased: (mouse) => fader.gainAt(mouse.y) + } + } + Text { + id: freqLabel + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.sp(1) + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: { + const hz = fader.band ? fader.band.frequency : 0; + return hz >= 1000 + ? (hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1) + "k" + : String(Math.round(hz)); + } + color: fader.selected ? Tokens.ink : Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.weight: fader.selected ? Font.DemiBold : Font.Normal + } + } + } + } + + // Precision controls are always scoped to the selected band. + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(4) + ColumnLayout { + Layout.preferredWidth: Style.sp(27) + spacing: Style.sp(1) + Text { + text: "BAND " + (root.selectedEqBand + 1) + " / 20" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + } + Text { + text: (root.activeBand().gain > 0 ? "+" : "") + + Number(root.activeBand().gain).toFixed(1) + " dB" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: Style.fs.lg + } + } + FxRow { + Layout.fillWidth: true + label: "Frequency" + naturalLabel: true + value: Math.round(root.activeBand().frequency) + " Hz" + from: 0 + to: 1 + val: Math.log(root.activeBand().frequency / 20) / Math.log(1000) + onMoved: (v) => root.setEqFrequency(root.selectedEqBand, v) + } + FxRow { + Layout.fillWidth: true + label: "Q" + naturalLabel: true + value: Number(root.activeBand().q).toFixed(1) + from: 0.3 + to: 10 + val: root.activeBand().q + onMoved: (v) => root.setEqQ(root.selectedEqBand, v) + } + } + } Hairline { Layout.fillWidth: true } // presets ColumnLayout { + visible: !root.equalizerOpen Layout.fillWidth: true spacing: Style.sp(3) Text { @@ -237,6 +543,7 @@ Item { id: r property string label: "" property string value: "" + property bool naturalLabel: false property real from: 0 property real to: 1 property real val: 0 @@ -246,7 +553,7 @@ Item { spacing: Style.sp(3) Text { - Layout.preferredWidth: Style.sp(16) + Layout.preferredWidth: r.naturalLabel ? implicitWidth : Style.sp(16) text: r.label color: Tokens.ink font.family: Style.fontUi diff --git a/client/chrome/TitleBar.qml b/client/chrome/TitleBar.qml index 5a02d575..d9fe5cf9 100644 --- a/client/chrome/TitleBar.qml +++ b/client/chrome/TitleBar.qml @@ -19,9 +19,11 @@ Rectangle { signal accountClicked(real gx, real gy) signal listenTogetherClicked() signal miniClicked() + signal audioLabClicked() property bool sidebarOpen: true property bool panelOpen: true + property bool audioLabOpen: false property bool discordOn: false implicitHeight: Style.titleBarH @@ -68,14 +70,6 @@ Rectangle { 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 } @@ -137,6 +131,8 @@ Rectangle { // --- search field (routes to the search page) --------------------------------------- Rectangle { id: searchField + // Home already has the full search/suggestion field. + visible: !Router.current || Router.current.page !== "home" Layout.preferredWidth: Style.sp(70) Layout.preferredHeight: Style.sp(8) Layout.alignment: Qt.AlignVCenter @@ -265,6 +261,16 @@ Rectangle { active: root.panelOpen onClicked: root.panelToggleClicked() } + IconButton { + visible: Prefs.audiophileMode + icon: "dashboard" + iconSize: Style.fs.lg + diameter: Style.sp(8) + tip: root.audioLabOpen ? "Hide Audio Lab" : "Show Audio Lab" + tipBelow: true + active: root.audioLabOpen + onClicked: root.audioLabClicked() + } IconButton { icon: "sound" iconSize: Style.fs.lg diff --git a/client/components/AudioLabWorkbench.qml b/client/components/AudioLabWorkbench.qml new file mode 100644 index 00000000..576fd902 --- /dev/null +++ b/client/components/AudioLabWorkbench.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// Single Audiophile instrument surface. Exactly one view owns audio capture. +// Spectrum keeps using the original Cava singleton; stereo scopes share one +// 25fps PCM capture bridge, activated only while their selected view is visible. +ColumnLayout { + id: root + property bool active: false + property string mode: "spectrum" + // Match the breathing room around Home controls: tabs sit above the panel, + // never pressed against the plot's top border. + spacing: Style.sp(4) + // Preferred size of the global top strip; App owns its actual height. + implicitHeight: Style.sp(48) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Chip { + text: "Spectrum" + active: root.mode === "spectrum" + onClicked: root.mode = "spectrum" + } + Chip { + text: "Oscilloscope" + active: root.mode === "oscilloscope" + onClicked: root.mode = "oscilloscope" + } + Chip { + text: "Vectorscope" + active: root.mode === "vectorscope" + onClicked: root.mode = "vectorscope" + } + Item { Layout.fillWidth: true } + } + + AudioSpectrumWorkbench { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.mode === "spectrum" + active: root.active && root.visible && root.mode === "spectrum" + } + AudioStereoScope { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.mode !== "spectrum" + active: root.active && root.visible && root.mode !== "spectrum" + instrument: root.mode + } +} diff --git a/client/components/AudioSpectrumWorkbench.qml b/client/components/AudioSpectrumWorkbench.qml new file mode 100644 index 00000000..3811ed49 --- /dev/null +++ b/client/components/AudioSpectrumWorkbench.qml @@ -0,0 +1,198 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// Audio Lab v1: real 40-band PipeWire/Cava spectrum, using the existing +// Spectrum singleton. No sample synthesis or simulated bars. The process only +// runs when this panel or another visible consumer has claimed it and music +// is playing. Curve and bars are two views of the same real spectrum. +Rectangle { + id: root + + property bool active: false + property string presentation: "bars" + + implicitHeight: Style.sp(46) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + clip: true + + function updateClaim() { + Spectrum.claim("ryotunes-audio-lab-library", root.active && root.visible); + } + onActiveChanged: updateClaim() + onVisibleChanged: updateClaim() + Component.onCompleted: updateClaim() + Component.onDestruction: Spectrum.claim("ryotunes-audio-lab-library", false) + + function rgba(c, alpha) { + return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255) + + "," + Math.round(c.b * 255) + "," + alpha + ")"; + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.sp(3) + spacing: Style.sp(2) + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(3) + + // Keep the view selector; the diagnostic header is unnecessary chrome. + Item { Layout.fillWidth: true } + Text { + text: "BARS" + color: root.presentation === "bars" ? Tokens.ink : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.weight: root.presentation === "bars" ? Font.DemiBold : Font.Normal + MouseArea { + anchors.fill: parent + anchors.margins: -Style.sp(2) + cursorShape: Qt.PointingHandCursor + onClicked: root.presentation = "bars" + } + } + Rectangle { + implicitWidth: 1 + implicitHeight: Style.sp(3) + color: Tokens.line + } + Text { + text: "CURVE" + color: root.presentation === "curve" ? Tokens.ink : Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.xs + font.weight: root.presentation === "curve" ? Font.DemiBold : Font.Normal + MouseArea { + anchors.fill: parent + anchors.margins: -Style.sp(2) + cursorShape: Qt.PointingHandCursor + onClicked: root.presentation = "curve" + } + } + } + + Canvas { + id: chart + Layout.fillWidth: true + Layout.fillHeight: true + renderTarget: Canvas.Image + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + Connections { + target: Spectrum + function onLevelsChanged() { if (root.active && root.visible) chart.requestPaint(); } + } + Connections { + target: root + function onPresentationChanged() { chart.requestPaint(); } + } + Connections { + target: Style + function onAccentChanged() { chart.requestPaint(); } + } + + onPaint: { + var c = getContext("2d"); + var w = width, h = height; + c.clearRect(0, 0, w, h); + if (w < 2 || h < 2) return; + var left = Style.sp(1), right = w - Style.sp(1); + var top = Style.sp(2), bottom = h - Style.sp(3); + var cw = right - left, ch = bottom - top; + if (cw <= 0 || ch <= 0) return; + + c.lineWidth = 1; + c.strokeStyle = root.rgba(Tokens.inkFaint, 0.13); + for (var gy = 1; gy < 4; gy++) { + var ly = top + ch * gy / 4; + c.beginPath(); c.moveTo(left, ly); c.lineTo(right, ly); c.stroke(); + } + for (var gx = 1; gx < 8; gx++) { + var lx = left + cw * gx / 8; + c.beginPath(); c.moveTo(lx, top); c.lineTo(lx, bottom); c.stroke(); + } + + var values = Spectrum.levels || []; + var n = Math.min(40, values.length); + if (!Spectrum.analysing || n < 1) return; + + var slot = cw / n; + var main = Style.accent; + var low = root.rgba(main, 0.28); + var bright = root.rgba(main, 0.92); + var grad = c.createLinearGradient(0, bottom, 0, top); + grad.addColorStop(0, low); + grad.addColorStop(1, bright); + + if (root.presentation === "curve") { + c.beginPath(); c.moveTo(left, bottom); + for (var k = 0; k < n; k++) { + var value = Math.max(0, Math.min(1, Number(values[k]) || 0)); + var px = left + (k + 0.5) * slot; + var py = bottom - Math.pow(value, 0.78) * ch; + c.lineTo(px, py); + } + c.lineTo(right, bottom); c.closePath(); + c.fillStyle = grad; c.fill(); + c.beginPath(); + for (var j = 0; j < n; j++) { + var a = Math.max(0, Math.min(1, Number(values[j]) || 0)); + var x = left + (j + 0.5) * slot; + var y = bottom - Math.pow(a, 0.78) * ch; + if (j === 0) c.moveTo(x, y); else c.lineTo(x, y); + } + c.strokeStyle = bright; c.lineWidth = 1.5; c.stroke(); + } else { + c.fillStyle = grad; + var bw = Math.max(1, slot * 0.77); + for (var i = 0; i < n; i++) { + var level = Math.max(0, Math.min(1, Number(values[i]) || 0)); + if (level <= 0) continue; + var bh = Math.max(1, Math.pow(level, 0.78) * ch); + c.fillRect(left + i * slot + (slot - bw) / 2, bottom - bh, bw, bh); + } + } + } + } + RowLayout { + Layout.fillWidth: true + Text { + text: "LOW" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + } + Item { Layout.fillWidth: true } + Text { + text: "MID" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + } + Item { Layout.fillWidth: true } + Text { + text: "HIGH" + color: Tokens.inkFaint + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + } + } + } + + Text { + anchors.centerIn: parent + visible: root.active && !Style.live + text: Playback.now ? "PAUSED · PLAY TO ANALYSE" : "PLAY A TRACK TO SEE THE SPECTRUM" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + } +} diff --git a/client/components/AudioStereoScope.qml b/client/components/AudioStereoScope.qml new file mode 100644 index 00000000..f1bfd58b --- /dev/null +++ b/client/components/AudioStereoScope.qml @@ -0,0 +1,165 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons +import "../" + +// Real stereo PCM views. One on-demand Python/ffmpeg bridge runs while the +// selected instrument is visible AND Ryotunes is playing. Never records audio +// to disk, and never runs while showing the ordinary Spectrum instrument. +Rectangle { + id: root + property bool active: false + property string instrument: "oscilloscope" // oscilloscope | vectorscope + property var sampleFrame: ({ wave: [], xy: [], phase: 0, rms: 0 }) + property string captureError: "" + readonly property bool analysing: root.active && root.visible && Style.live + + implicitHeight: Style.sp(46) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.line + clip: true + + function rgba(color, alpha) { + return "rgba(" + Math.round(color.r * 255) + "," + Math.round(color.g * 255) + + "," + Math.round(color.b * 255) + "," + alpha + ")"; + } + function readFrame(line) { + try { + var obj = JSON.parse(line); + if (obj.error) { + root.captureError = String(obj.error); + return; + } + if (!Array.isArray(obj.wave) || !Array.isArray(obj.xy)) return; + root.captureError = ""; + root.sampleFrame = obj; + } catch (error) { + root.captureError = "Stereo capture returned invalid data"; + } + } + + onAnalysingChanged: { + root.sampleFrame = ({ wave: [], xy: [], phase: 0, rms: 0 }); + if (root.analysing) root.captureError = ""; + } + onInstrumentChanged: plot.requestPaint() + onSampleFrameChanged: plot.requestPaint() + + Process { + id: capture + // Quickshell.shellDir is the root client directory, not the cwd. + command: ["python3", Quickshell.shellDir + "/tools/audio_scope_bridge.py"] + property bool backoff: false + running: root.analysing && !capture.backoff + stdout: SplitParser { + splitMarker: "\n" + onRead: (line) => root.readFrame(line) + } + onExited: { + if (root.analysing) { + capture.backoff = true; + restartTimer.restart(); + } + } + } + Timer { id: restartTimer; interval: 1400; onTriggered: capture.backoff = false } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.sp(3) + spacing: Style.sp(2) + + Canvas { + id: plot + Layout.fillWidth: true + Layout.fillHeight: true + renderTarget: Canvas.Image + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + Connections { + target: Style + function onAccentChanged(): void { plot.requestPaint(); } + } + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height; + ctx.clearRect(0, 0, w, h); + if (w < 4 || h < 4) return; + var left = Style.sp(2), top = Style.sp(2); + var usableW = w - 2 * left, usableH = h - 2 * top; + if (usableW <= 0 || usableH <= 0) return; + + ctx.strokeStyle = root.rgba(Tokens.inkFaint, 0.15); + ctx.lineWidth = 1; + for (var i = 1; i < 8; ++i) { + var gx = left + usableW * i / 8; + ctx.beginPath(); ctx.moveTo(gx, top); ctx.lineTo(gx, top + usableH); ctx.stroke(); + } + for (var j = 1; j < 6; ++j) { + var gy = top + usableH * j / 6; + ctx.beginPath(); ctx.moveTo(left, gy); ctx.lineTo(left + usableW, gy); ctx.stroke(); + } + + ctx.strokeStyle = root.rgba(Tokens.inkFaint, 0.35); + ctx.beginPath(); + ctx.moveTo(left, top + usableH / 2); + ctx.lineTo(left + usableW, top + usableH / 2); + ctx.stroke(); + if (!root.analysing) return; + + var packet = root.sampleFrame; + if (root.instrument === "oscilloscope") { + var values = packet.wave || []; + if (values.length < 2) return; + ctx.lineWidth = 1.6; + ctx.strokeStyle = root.rgba(Style.accent, 0.95); + ctx.beginPath(); + for (var k = 0; k < values.length; k++) { + var vx = left + k * usableW / (values.length - 1); + var vy = top + usableH / 2 - Math.max(-1, Math.min(1, values[k])) * usableH * 0.45; + if (k === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy); + } + ctx.stroke(); + } else { + // X = (L - R)/sqrt(2), Y = (L + R)/sqrt(2). + // Mono is vertical; wide stereo spreads horizontally. + var pairs = packet.xy || []; + if (pairs.length < 2) return; + var side = Math.min(usableW, usableH) * 0.42; + var cx = left + usableW / 2, cy = top + usableH / 2; + ctx.strokeStyle = root.rgba(Tokens.inkFaint, 0.26); + ctx.beginPath(); ctx.moveTo(cx, cy - side); ctx.lineTo(cx, cy + side); + ctx.moveTo(cx - side, cy); ctx.lineTo(cx + side, cy); ctx.stroke(); + ctx.fillStyle = root.rgba(Style.accent, 0.48); + for (var v = 0; v < pairs.length; v++) { + var a = Number(pairs[v][0]) || 0; + var b = Number(pairs[v][1]) || 0; + var px = cx + (a - b) * side * 0.70710678; + var py = cy - (a + b) * side * 0.70710678; + ctx.fillRect(px - 1, py - 1, 2, 2); + } + } + } + } + } + + Text { + anchors.centerIn: parent + visible: !root.analysing || root.captureError !== "" + || (root.sampleFrame.rms || 0) < 0.0001 + text: root.captureError !== "" ? root.captureError + : !Style.live ? (Playback.now ? "PAUSED · PLAY TO ANALYSE" : "PLAY A TRACK TO ANALYSE") + : "WAITING FOR STEREO AUDIO" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.sm + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + width: parent.width - Style.sp(8) + } +} diff --git a/client/components/Chip.qml b/client/components/Chip.qml index ef78e1d5..c241b634 100644 --- a/client/components/Chip.qml +++ b/client/components/Chip.qml @@ -14,7 +14,7 @@ Rectangle { implicitHeight: Style.sp(8) implicitWidth: label.implicitWidth + Style.sp(8) - radius: height / 2 + radius: Style.radius color: active ? Tokens.bone : chipHover.hovered ? Tokens.tint10 : "transparent" border.width: 1 border.color: active ? Tokens.bone : chipHover.hovered ? Tokens.lineStrong : Tokens.line diff --git a/client/components/HomeListeningHero.qml b/client/components/HomeListeningHero.qml new file mode 100644 index 00000000..567d88ee --- /dev/null +++ b/client/components/HomeListeningHero.qml @@ -0,0 +1,146 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import Ryoku.Ui.Singletons +import "../" + +// A listening-first Home feature with an art-derived, static ambient wash. +// Riff-inspired composition, but Ryoku tokens, typography and native playback. +// The real player and its transport remain authoritative; no additional audio engine. +Rectangle { + id: root + + readonly property var now: Playback.now + readonly property bool compact: width < Style.sp(180) + readonly property int artSize: Style.sp(root.compact ? 34 : 48) + signal focusRequested() + + implicitHeight: Style.sp(root.compact ? 48 : 59) + radius: Style.radiusCard + color: Tokens.paperLift + border.width: 1 + border.color: Tokens.lineSoft + clip: true + + // Static cover atmosphere. One asynchronously loaded, bounded thumbnail; no blur + // shader, timer, floating gradient or per-frame effect. + Image { + id: ambientCover + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + width: parent.width * 0.56 + source: root.now && root.now.thumbnail ? Style.thumb(root.now.thumbnail, 640) : "" + sourceSize: Qt.size(640, 640) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + opacity: status === Image.Ready ? (root.compact ? 0.15 : 0.28) : 0 + Behavior on opacity { + enabled: !Tokens.reduceMotion + NumberAnimation { duration: Style.motion.slow } + } + } + Rectangle { + anchors.fill: parent + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Tokens.paperLift } + GradientStop { + position: 0.57 + color: Qt.rgba(Tokens.paperLift.r, Tokens.paperLift.g, Tokens.paperLift.b, 0.91) + } + GradientStop { + position: 1.0 + color: Qt.rgba(Tokens.paperLift.r, Tokens.paperLift.g, Tokens.paperLift.b, 0.46) + } + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: Style.sp(root.compact ? 4 : 5) + spacing: Style.sp(root.compact ? 3 : 7) + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 0 + spacing: Style.sp(2) + + Text { + text: Playback.paused ? "CONTINUE LISTENING" : "NOW PLAYING" + color: Tokens.inkMuted + font.family: Style.fontMono + font.pixelSize: Style.fs.micro + font.letterSpacing: Style.trackMicro + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: root.now ? root.now.title : "" + color: Tokens.ink + font.family: Style.fontDisplay + font.pixelSize: root.compact ? Style.fs.xl : Style.fs.hero + elide: Text.ElideRight + textFormat: Text.PlainText + } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 0 + text: root.now ? root.now.artists : "" + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.md + elide: Text.ElideRight + textFormat: Text.PlainText + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: Style.sp(2) + Btn { + text: Playback.paused ? "Resume" : "Pause" + icon: Playback.paused ? "play" : "pause" + primary: true + onClicked: Playback.togglePause().catch((e) => + Playback.toast(String(e), "error")) + } + Btn { + visible: root.width >= Style.sp(147) + text: "Focus" + onClicked: root.focusRequested() + } + IconButton { + visible: root.width >= Style.sp(160) + icon: "queue" + tip: "Open queue" + outlined: true + diameter: Style.ctlH + onClicked: Playback.nowPlayingRequested("queue") + } + Item { Layout.fillWidth: true } + } + } + + Item { + Layout.preferredWidth: root.artSize + Layout.preferredHeight: root.artSize + Layout.alignment: Qt.AlignVCenter + Artwork { + anchors.fill: parent + px: root.artSize + cornerRadius: Style.radiusCard + url: root.now && root.now.thumbnail ? root.now.thumbnail : "" + } + // Artwork doubles as the entrance to the full Focus listening view. + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.focusRequested() + } + } + } +} diff --git a/client/components/MediaCard.qml b/client/components/MediaCard.qml index 4d767560..f9ff43c5 100644 --- a/client/components/MediaCard.qml +++ b/client/components/MediaCard.qml @@ -85,6 +85,7 @@ Item { Item { Layout.fillWidth: true implicitHeight: width + clip: true Artwork { id: art @@ -93,6 +94,11 @@ Item { px: root.cardWidth round: root.round cornerRadius: root.round ? art.width / 2 : Style.radiusCard + scale: cardHover.hovered && !Tokens.reduceMotion ? 1.035 : 1 + Behavior on scale { + enabled: !Tokens.reduceMotion + NumberAnimation { duration: Style.motion.snap; easing.type: Easing.OutCubic } + } placeholderIcon: root.round ? "user" : (root.item && Ids.isOnRepeatId(root.item.id)) ? "on-repeat" : "music" } diff --git a/client/components/NowPlayingCard.qml b/client/components/NowPlayingCard.qml index dfcf7759..6f719252 100644 --- a/client/components/NowPlayingCard.qml +++ b/client/components/NowPlayingCard.qml @@ -256,7 +256,7 @@ Rectangle { Layout.alignment: Qt.AlignVCenter implicitHeight: Style.sp(7) implicitWidth: pillRow.implicitWidth + Style.sp(5) - radius: height / 2 + radius: Style.radius color: pillHover.hovered ? Tokens.tint10 : "transparent" border.width: 1 border.color: pillHover.hovered ? Tokens.lineStrong : Tokens.line diff --git a/client/components/Pill.qml b/client/components/Pill.qml index 5600d7d9..798797c0 100644 --- a/client/components/Pill.qml +++ b/client/components/Pill.qml @@ -21,7 +21,7 @@ Item { Rectangle { anchors.fill: parent - radius: height / 2 + radius: Style.radius color: root.primary ? Tokens.bone : ma.pressed ? Tokens.tint16 : hover.hovered ? Tokens.tint5 diff --git a/client/components/Toggle.qml b/client/components/Toggle.qml index 4d6edfee..b553e959 100644 --- a/client/components/Toggle.qml +++ b/client/components/Toggle.qml @@ -18,7 +18,7 @@ Item { Rectangle { anchors.fill: parent - radius: height / 2 + radius: Style.radius color: root.checked ? Tokens.sun : Tokens.tint16 border.width: 1 border.color: root.checked ? Tokens.sun : Tokens.line diff --git a/client/pages/HomePage.qml b/client/pages/HomePage.qml index 00b85446..5833c883 100644 --- a/client/pages/HomePage.qml +++ b/client/pages/HomePage.qml @@ -20,6 +20,11 @@ import "../lib/recommendations.js" as Recommendations Item { id: page + // The App-owned Audio Lab switch is passed only to HomePage. On Home it + // belongs in the feed between the search box and the listening hero. + property bool audioLabEnabled: false + signal focusRequested() + property var home: null property var chips: [] property var forgotten: null @@ -195,7 +200,7 @@ Item { cacheBuffer: Math.max(0, Math.round(height * 1.5)) boundsBehavior: Flickable.StopAtBounds model: blocks - spacing: Style.sp(10) + spacing: Style.sp(7) // HomeFeed releases its top pin without restoring a stale pre-layout offset. onContentYChanged: page.maybeLoadMore() @@ -220,12 +225,12 @@ Item { 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) + implicitHeight: headerCol.implicitHeight + Style.sp(5) ColumnLayout { id: headerCol width: parent.width - spacing: Style.sp(10) + spacing: Style.sp(6) // 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 @@ -239,34 +244,13 @@ Item { ColumnLayout { Layout.fillWidth: true - Layout.maximumWidth: Style.sp(120) + // Fill narrower pages; reserve the narrow reading column only + // beside the wide-layout listening card. + Layout.maximumWidth: 16777215 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) : "") @@ -327,70 +311,25 @@ Item { } } - // 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 { + // Keep the original shallow analyser below Home's search bar, + // above the artwork-led listening card. The feed owns the height, + // so tracks and recommendations remain reachable by scrolling. + AudioLabWorkbench { + Layout.fillWidth: true + Layout.preferredHeight: Style.sp(48) + visible: page.audioLabEnabled + active: visible + } + + // One listening stage across desktop sizes, no duplicated transport. + HomeListeningHero { 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") - } + onFocusRequested: page.focusRequested() } HomePersonal { @@ -404,7 +343,7 @@ Item { Item { Layout.fillWidth: true implicitHeight: chipRow.implicitHeight - visible: page.chips.length > 0 + visible: page.chips.length > 0 || page.selected === "" Flickable { id: chipsFlick anchors.fill: parent @@ -417,6 +356,7 @@ Item { id: chipRow spacing: Style.sp(2) Chip { + visible: page.chips.length > 0 text: "All" active: page.selected === "" onClicked: page.load("") @@ -430,6 +370,13 @@ Item { onClicked: page.load(page.selected === modelData.params ? "" : modelData.params) } } + // The creation action stays discoverable without a large, + // otherwise empty section taking space above the music. + Chip { + visible: page.selected === "" + text: "+ Shortcut" + onClicked: shortcutPicker.open() + } } } Rectangle { @@ -446,17 +393,17 @@ Item { } } - // shortcuts (pinned, unfiltered only) — bordered 56 px cards, then a dashed - // "Add shortcut" card of the same height + // Show the full shortcuts section once populated. Before then, + // the filter rail offers a compact "+ Shortcut" action instead. ColumnLayout { Layout.fillWidth: true - visible: page.selected === "" + visible: false 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 ? "選" : "" } + SectionHeading { Layout.fillWidth: true; title: "Shortcuts"; mark: Style.decorRich ? "選" : "" } } Flow { id: pinFlow diff --git a/client/pages/SettingsPage.qml b/client/pages/SettingsPage.qml index f3ba50a7..9d90397d 100644 --- a/client/pages/SettingsPage.qml +++ b/client/pages/SettingsPage.qml @@ -61,6 +61,7 @@ Item { readonly property var sections: [ { k: "general", l: "General", jp: "全般" }, { k: "playback", l: "Playback", jp: "再生" }, + { k: "audiophile", l: "Audiophile", jp: "音響" }, { k: "downloads", l: "Downloads", jp: "保存" }, { k: "data", l: "Data & storage", jp: "保存" }, { k: "account", l: "Account", jp: "鍵" }, @@ -550,6 +551,7 @@ Item { var h = 0; if (page.section === "general") h = generalCol.implicitHeight; else if (page.section === "playback") h = playbackCol.implicitHeight; + else if (page.section === "audiophile") h = audiophileCol.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; @@ -854,6 +856,117 @@ Item { } } + // ─────────────────────────── AUDIOPHILE MODE ─────────────────────────── + ColumnLayout { + id: audiophileCol + visible: page.section === "audiophile" + 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(4) + + SectionHeading { Layout.fillWidth: true; title: "Audiophile Mode"; mark: "音響" } + + Text { + Layout.fillWidth: true + text: "A deeper listening workspace, without turning the everyday music player into a control room." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + + ToggleRow { + title: "Enable Audiophile Mode" + desc: "Unlock the advanced visualisers and listening tools. Hidden analysers stay idle when their view is closed." + value: Prefs.audiophileMode + onFlipped: (on) => { + Prefs.audiophileMode = on; + Prefs.save(); + } + } + + Text { + Layout.fillWidth: true + text: "This is a workspace switch, not an audio bypass. Your EQ curve and enabled state are preserved independently." + color: Tokens.inkFaint + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + + SectionHeading { + Layout.fillWidth: true + Layout.topMargin: Style.sp(3) + visible: Prefs.audiophileMode + title: "Instruments" + mark: "01" + } + + RowLayout { + Layout.fillWidth: true + visible: Prefs.audiophileMode + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { text: "Spectrum analyser"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { Layout.fillWidth: true; text: "Real 40-band spectrum, with Bars and Curve views. Available throughout Ryotunes."; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap } + } + Pill { + label: "Show Audio Lab" + onClicked: Prefs.showAudioLabRequested() + } + } + + RowLayout { + Layout.fillWidth: true + visible: Prefs.audiophileMode + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { text: "20-band parametric EQ"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { Layout.fillWidth: true; text: "Frequency, gain and Q, with the saved curve controlled in Sound. EQ playback is independent of this mode."; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap } + } + Pill { + label: "Open Sound" + onClicked: Playback.soundRequested() + } + } + + RowLayout { + Layout.fillWidth: true + visible: Prefs.audiophileMode + spacing: Style.sp(4) + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { text: "Stereo instruments"; color: Tokens.ink; font.family: Style.fontUi; font.pixelSize: Style.fs.md; font.weight: Font.Medium } + Text { Layout.fillWidth: true; text: "Oscilloscope and vectorscope from real stereo samples, available on any page."; color: Tokens.inkMuted; font.family: Style.fontUi; font.pixelSize: Style.fs.sm; wrapMode: Text.WordWrap } + } + Pill { + label: "Show Audio Lab" + onClicked: Prefs.showAudioLabRequested() + } + } + + SectionHeading { + Layout.fillWidth: true + Layout.topMargin: Style.sp(3) + visible: Prefs.audiophileMode + title: "On the roadmap" + mark: "02" + } + Text { + Layout.fillWidth: true + visible: Prefs.audiophileMode + text: "Enhanced lyrics: optional cinematic presentation, retaining existing sync, timing offsets and click-to-seek." + color: Tokens.inkMuted + font.family: Style.fontUi + font.pixelSize: Style.fs.sm + wrapMode: Text.WordWrap + } + } + // ─────────────────────────── PLAYBACK ─────────────────────────── ColumnLayout { id: playbackCol diff --git a/client/shell.qml b/client/shell.qml index 6361e13d..9d677364 100644 --- a/client/shell.qml +++ b/client/shell.qml @@ -169,9 +169,14 @@ ShellRoot { } Connections { target: Daemon + function onEvent(name: string, data: var): void { - if (name === "show") + if (name === "show") { shellRoot.present(); + } else if (name === "quit") { + Daemon.quitting = true; + Qt.quit(); + } } } IpcHandler { diff --git a/client/tools/audio_scope_bridge.py b/client/tools/audio_scope_bridge.py new file mode 100644 index 00000000..23bfa31f --- /dev/null +++ b/client/tools/audio_scope_bridge.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""On-demand Ryotunes stereo PCM -> small newline-delimited JSON frames. + +Requires existing ffmpeg and pactl. Captures ONLY a Pulse/PipeWire output monitor. +Never writes audio to a file; terminates ffmpeg on exit. No fake input frames. +""" +from __future__ import annotations + +import array +import json +import math +import os +import signal +import subprocess +import sys + +RATE = 48000 +FRAMES = 1920 # 25 fps, one process shared by BOTH scope views +TRACE_POINTS = 180 +PHASE_POINTS = 240 + + +def analyse(audio: array.array, last_scale: float) -> tuple[dict, float]: + left = audio[::2] + right = audio[1::2] + n = min(len(left), len(right)) + if n < 2: + raise ValueError("Not enough stereo samples") + + power_l = sum(float(a) * a for a in left) + power_r = sum(float(b) * b for b in right) + cross = sum(float(a) * float(b) for a, b in zip(left, right)) + denom = math.sqrt(power_l * power_r) + phase = max(-1.0, min(1.0, cross / denom)) if denom > 1e-12 else 0.0 + peak = max(max(abs(x) for x in left), max(abs(x) for x in right)) + rms = math.sqrt((power_l + power_r) / (2 * n)) + # The amplitude scaling is intentionally indicated in the UI: the shapes + # are measured stereo samples, but a quiet master should still be visible. + desired = min(18.0, 0.85 / max(0.04, peak)) + scale = 0.75 * last_scale + 0.25 * desired + if rms < 0.0001: + return {"wave": [], "xy": [], "phase": 0.0, "rms": 0.0}, scale + + mono = [(float(a) + float(b)) * 0.5 for a, b in zip(left, right)] + # Rising zero-crossing triggers keep a roughly periodic tone stable. + start = next((j for j in range(12, min(n - 1000, 650)) + if mono[j] <= 0 and mono[j + 1] > 0), 0) + window = min(1000, n - start) + wave = [round(max(-1, min(1, mono[start + (i * (window - 1) // (TRACE_POINTS - 1))] * scale)), 4) + for i in range(TRACE_POINTS)] + xy = [[round(max(-1, min(1, float(left[i * (n - 1) // (PHASE_POINTS - 1)]) * scale)), 4), + round(max(-1, min(1, float(right[i * (n - 1) // (PHASE_POINTS - 1)]) * scale)), 4)] + for i in range(PHASE_POINTS)] + return {"wave": wave, "xy": xy, "phase": round(phase, 3), "rms": round(rms, 5)}, scale + + +def self_test() -> None: + def stereo(fn): + data = array.array("f") + for i in range(FRAMES): + l = 0.12 * math.sin(i * 2 * math.pi * 440 / RATE) + r = fn(l, i) + data.extend((l, r)) + return data + + same, _ = analyse(stereo(lambda l, i: l), 1.0) + inverse, _ = analyse(stereo(lambda l, i: -l), 1.0) + independent, _ = analyse(stereo(lambda l, i: 0.12 * math.cos(i * 2 * math.pi * 440 / RATE)), 1.0) + assert len(same["wave"]) == TRACE_POINTS + assert len(same["xy"]) == PHASE_POINTS + assert same["phase"] > 0.999, same["phase"] + assert inverse["phase"] < -0.999, inverse["phase"] + assert abs(independent["phase"]) < 0.05, independent["phase"] + assert all(-1 <= x <= 1 for x in same["wave"]) + print("PASS: stereo correlation, real sample trace and frame bounds") + + +def sink_monitor() -> str: + sink = subprocess.check_output(["pactl", "get-default-sink"], text=True, timeout=5).strip() + if not sink or "/" in sink or "\n" in sink: + raise RuntimeError("Invalid default PipeWire sink") + source = sink + ".monitor" + lines = subprocess.check_output(["pactl", "list", "short", "sources"], text=True, timeout=5) + names = {parts[1] for line in lines.splitlines() if (parts := line.split()) and len(parts) > 1} + if source not in names: + raise RuntimeError("Default output monitor is unavailable") + return source + + +def capture() -> None: + source = sink_monitor() + command = [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-f", "pulse", "-sample_rate", str(RATE), "-channels", "2", "-i", source, + "-vn", "-ac", "2", "-f", "f32le", "-acodec", "pcm_f32le", "-", + ] + proc = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=FRAMES * 8) + try: + assert proc.stdout is not None + scale = 1.0 + while True: + raw = proc.stdout.read(FRAMES * 8) + if len(raw) != FRAMES * 8: + break + audio = array.array("f") + audio.frombytes(raw) + if sys.byteorder != "little": + audio.byteswap() + payload, scale = analyse(audio, scale) + print(json.dumps(payload, separators=(",", ":")), flush=True) + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def main() -> None: + if sys.argv[1:] == ["--self-test"]: + self_test() + return + signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0)) + try: + capture() + except (OSError, subprocess.SubprocessError, RuntimeError) as error: + print(json.dumps({"error": str(error)}), flush=True) + sys.exit(1) + except BrokenPipeError: + return + + +if __name__ == "__main__": + main() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 50d2613a..9f8ba7ba 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -18,6 +18,7 @@ pub mod session; pub mod soundcloud_bridge; pub mod spotify; pub mod spotify_bridge; +pub mod soloist; pub mod state; pub mod tidal_bridge; pub mod update; diff --git a/crates/core/src/soloist.rs b/crates/core/src/soloist.rs new file mode 100644 index 00000000..cdab7e20 --- /dev/null +++ b/crates/core/src/soloist.rs @@ -0,0 +1,443 @@ +//! Optional bridge to Spotify Soloist's localhost-only WebSocket API. +//! +//! This module never launches Soloist, receives an API key, reads its credential store, +//! or changes the existing librespot/MPV player. It connects to the *already-running* +//! user-owned Soloist process for a short RPC and closes the socket afterward. + +use std::net::IpAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{anyhow, bail, ensure, Context as _, Result}; +use futures_util::{SinkExt as _, StreamExt as _}; +use serde_json::{json, Value}; +use tokio::time::{timeout, Instant}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_REPLY_BYTES: usize = 2 * 1024 * 1024; + +/// The bridge's probe state is separate from Spotify's original librespot auth state. +/// A missing Soloist process must never mark the old Spotify provider signed out. +pub struct SoloistBridge { + data_dir: PathBuf, +} + +impl SoloistBridge { + pub fn discover() -> Self { + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); + let data_dir = + std::env::var_os("RYOTUNES_SOLOIST_DATA_DIR").map(PathBuf::from).unwrap_or_else(|| { + // Respect the isolated test session we have already paired; the + // production default remains the ordinary Soloist data directory. + let eval = home.join(".local/share/ryotunes-soloist-eval"); + if eval.is_dir() { + eval + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")) + .join("soloist") + } + }); + Self { data_dir } + } + + fn endpoint(&self) -> Result { + let raw_host = std::fs::read_to_string(self.data_dir.join("ws.addr")) + .context("Soloist is not running with its local WebSocket API (ws.addr missing)")?; + let host = raw_host.trim(); + let address: IpAddr = host.parse().context("invalid Soloist WebSocket address")?; + ensure!(address.is_loopback(), "refusing a non-local Soloist WebSocket address"); + let raw_port = std::fs::read_to_string(self.data_dir.join("ws.port")) + .context("Soloist WebSocket port is unavailable (ws.port missing)")?; + let port: u16 = raw_port.trim().parse().context("invalid Soloist WebSocket port")?; + ensure!(port != 0, "Soloist WebSocket port must be nonzero"); + let host = match address { + IpAddr::V4(v4) => v4.to_string(), + IpAddr::V6(v6) => format!("[{v6}]"), + }; + Ok(format!("ws://{host}:{port}")) + } + + async fn connect( + &self, + ) -> Result< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + > { + let url = self.endpoint()?; + let (socket, _) = timeout(CONNECT_TIMEOUT, connect_async(url.as_str())) + .await + .context("timed out contacting Soloist's local WebSocket")? + .context("cannot connect to Soloist's local WebSocket")?; + Ok(socket) + } + + /// Read the actual authenticated/active-device status, not a guessed process state. + pub async fn status(&self) -> Result { + let mut socket = self.connect().await?; + Self::send(&mut socket, json!({"type":"command", "command":"get_auth_state"})).await?; + Self::receive(&mut socket, "auth_state", None).await + } + + /// The official full snapshot, plus a Ryotunes-friendly rendering of its item. + pub async fn now(&self) -> Result { + let mut socket = self.connect().await?; + Self::send(&mut socket, json!({"type":"command", "command":"get_state"})).await?; + let raw = Self::receive(&mut socket, "playback_state", None).await?; + Ok(json!({ "now": normalize_now(&raw), "source": raw })) + } + + pub async fn queue(&self, limit: u32) -> Result { + ensure!(limit <= 80, "Soloist queue limit must not exceed 80"); + let mut socket = self.connect().await?; + Self::send(&mut socket, json!({"type":"command", "command":"get_queue", "limit": limit})) + .await?; + Self::receive(&mut socket, "queue_changed", None).await + } + + /// Observe the existing Soloist playback process. No playback commands are issued. + /// Each incoming notification is published independently of the legacy MPV player. + /// The caller owns reconnection and the lifetime of the listening task. + pub async fn watch_stream(&self, mut publish: F) -> Result<()> + where + F: FnMut(Value) + Send, + { + let mut socket = self.connect().await?; + + // A new observer can attach after a track started. Request snapshots so + // its subscribers do not depend on receiving a future change first. + Self::send(&mut socket, json!({"type":"command", "command":"get_state"})).await?; + Self::send(&mut socket, json!({"type":"command", "command":"get_queue", "limit": 10})) + .await?; + + let mut keepalive = tokio::time::interval(Duration::from_secs(30)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = keepalive.tick() => { + socket.send(Message::Ping(Vec::new().into())) + .await.context("cannot ping Soloist event socket")?; + } + incoming = socket.next() => { + let incoming = incoming + .ok_or_else(|| anyhow!("Soloist event socket closed"))? + .context("Soloist event WebSocket error")?; + match incoming { + Message::Text(text) => { + if text.len() > MAX_REPLY_BYTES { + tracing::warn!("dropping oversized Soloist event"); + continue; + } + let source: Value = match serde_json::from_str(&text) { + Ok(value) => value, + Err(error) => { + tracing::debug!(%error, "ignoring invalid Soloist event JSON"); + continue; + } + }; + if let Some(event) = event_payload(source) { + publish(event); + } + } + Message::Ping(data) => { + socket.send(Message::Pong(data)) + .await.context("cannot reply to Soloist ping")?; + } + Message::Close(_) => bail!("Soloist closed its event stream"), + _ => {} + } + } + } + } + } + + /// Explicit whitelist: never allow arbitrary messages or any account/auth commands. + /// A successful command_result means *dispatched*, not that Spotify has acted yet. + pub async fn control(&self, action: &str, args: &Value) -> Result { + let mut socket = self.connect().await?; + let requests = control_messages(action, args)?; + for request in requests { + let expected = request["command"].as_str().unwrap_or_default().to_owned(); + Self::send(&mut socket, request).await?; + Self::receive(&mut socket, "command_result", Some(&expected)).await?; + } + Ok(json!({ "accepted": true, "action": action })) + } + + async fn send(socket: &mut tokio_tungstenite::WebSocketStream, value: Value) -> Result<()> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + socket + .send(Message::Text(value.to_string().into())) + .await + .context("cannot send Soloist request") + } + + async fn receive( + socket: &mut tokio_tungstenite::WebSocketStream, + desired_type: &str, + desired_command: Option<&str>, + ) -> Result + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let deadline = Instant::now() + REPLY_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("Soloist did not return {desired_type} in time"); + } + let frame = timeout(remaining, socket.next()) + .await + .context("Soloist response timed out")? + .ok_or_else(|| anyhow!("Soloist closed the WebSocket"))? + .context("Soloist WebSocket error")?; + match frame { + Message::Text(text) => { + ensure!( + text.len() <= MAX_REPLY_BYTES, + "Soloist response exceeds the size limit" + ); + let response: Value = + serde_json::from_str(&text).context("invalid JSON in Soloist reply")?; + let kind = response["type"].as_str().unwrap_or(""); + if kind == "error" { + bail!( + "Soloist: {}", + response["message"].as_str().unwrap_or("unknown error") + ); + } + if kind == desired_type + && desired_command + .is_none_or(|name| response["command"].as_str() == Some(name)) + { + return Ok(response); + } + // The websocket emits auth_state and possibly playback_state on + // connect. Ignore unsolicited events until our requested response. + } + Message::Ping(bytes) => { + socket + .send(Message::Pong(bytes)) + .await + .context("cannot answer Soloist ping")?; + } + Message::Close(_) => bail!("Soloist closed the WebSocket during the request"), + _ => {} + } + } + } +} + +fn valid_spotify_uri(uri: &str, track_only: bool) -> Result<()> { + let allowed = ["spotify:track:", "spotify:album:", "spotify:playlist:", "spotify:episode:"]; + let prefix = allowed.iter().find(|prefix| uri.starts_with(**prefix)); + ensure!(prefix.is_some(), "unsupported Spotify playback URI"); + ensure!(!track_only || prefix == Some(&"spotify:track:"), "queue items must be Spotify tracks"); + let id = uri.rsplit(':').next().unwrap_or(""); + ensure!( + id.len() == 22 && id.bytes().all(|b| b.is_ascii_alphanumeric()), + "invalid Spotify URI identifier" + ); + Ok(()) +} + +fn control_messages(action: &str, args: &Value) -> Result> { + let msg = |name| json!({ "type": "command", "command": name }); + let requests = match action { + "play" => { + let mut request = msg("play"); + if let Some(uri) = args.get("uri") { + let uri = uri.as_str().ok_or_else(|| anyhow!("uri must be a string"))?; + valid_spotify_uri(uri, false)?; + request["uri"] = json!(uri); + } + vec![request] + } + "pause" | "skip_next" | "skip_prev" | "activate" | "deactivate" => vec![msg(action)], + "seek" => { + let pos = + args["positionMs"].as_u64().context("positionMs must be a nonnegative integer")?; + ensure!(pos <= 86_400_000, "seek position exceeds 24 hours"); + vec![json!({"type":"command", "command":"seek", "position_ms": pos})] + } + "volume" => { + let v = args["volume"].as_u64().context("volume must be an integer")?; + ensure!(v <= 100, "volume must be 0–100"); + vec![json!({"type":"command", "command":"set_volume", "volume": v})] + } + "shuffle" => { + let value = args["enabled"].as_bool().context("enabled must be a boolean")?; + vec![json!({"type":"command", "command":"set_shuffle", "enabled": value})] + } + "repeat" => match args["mode"].as_str() { + Some("off") => vec![ + json!({"type":"command", "command":"set_repeat_track", "enabled": false}), + json!({"type":"command", "command":"set_repeat_context", "enabled": false}), + ], + Some("context") => vec![ + json!({"type":"command", "command":"set_repeat_track", "enabled": false}), + json!({"type":"command", "command":"set_repeat_context", "enabled": true}), + ], + Some("track") => vec![ + json!({"type":"command", "command":"set_repeat_context", "enabled": false}), + json!({"type":"command", "command":"set_repeat_track", "enabled": true}), + ], + _ => bail!("repeat mode must be off, context or track"), + }, + "add_to_queue" => { + let uri = args["uri"].as_str().context("uri must be a Spotify track URI")?; + valid_spotify_uri(uri, true)?; + vec![json!({"type":"command", "command":"add_to_queue", "uri": uri})] + } + _ => bail!("unsupported Soloist control action: {action}"), + }; + Ok(requests) +} + +/// Snapshot mapping for the current Ryotunes `SongItem` conventions. +/// Keep the raw source alongside it; this does not yet replace the live MPV model. +pub fn normalize_now(source: &Value) -> Value { + let item = &source["item"]; + let decorations = &item["decorations"]; + let creators = decorations["creators"].as_array(); + let artists = creators + .map(|list| { + list.iter() + .filter_map(|person| person["entity"]["decorations"]["identity"]["name"].as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let covers = decorations["visual_identity"]["cover"].as_array(); + let thumbnail = covers.and_then(|list| { + list.iter() + .find(|cover| cover["size"] == "large") + .or_else(|| list.iter().find(|cover| cover["size"] == "xlarge")) + .or_else(|| list.last()) + .and_then(|cover| cover["url"].as_str()) + }); + let status = source["status"].as_str().unwrap_or("idle"); + json!({ + "videoId": item["uri"].as_str(), + "title": decorations["identity"]["name"].as_str(), + "artists": artists, + "album": decorations["parent"]["entity"]["decorations"]["identity"]["name"].as_str(), + "albumId": decorations["parent"]["entity"]["uri"].as_str(), + "thumbnail": thumbnail, + "durationMs": decorations["playback"]["duration_ms"].as_u64(), + "positionMs": source["position"]["position_ms"].as_u64(), + "timestampMs": source["position"]["timestamp_ms"].as_u64(), + "speed": source["position"]["speed"].as_f64(), + "volume": source["volume"].as_u64(), + "paused": status != "playing", + "status": status, + "isActive": source["is_active"].as_bool().unwrap_or(false), + "availableActions": &source["available_actions"], + "contextUri": source["context"]["uri"].as_str(), + }) +} + +/// Explicit notification allowlist: never forward auth or arbitrary command replies +/// into the UI event bus. Keep full provider data under `source`; the normalised +/// `now` is included only for complete playback snapshots. +fn event_payload(source: Value) -> Option { + let kind = source.get("type")?.as_str()?.to_owned(); + if !matches!( + kind.as_str(), + "playback_state" + | "playback_changed" + | "position_sync" + | "device_changed" + | "queue_changed" + | "track_changed" + | "volume_changed" + | "options_changed" + ) { + return None; + } + let mut event = json!({ "kind": kind, "source": source }); + if kind == "playback_state" { + event["now"] = normalize_now(&event["source"]); + } + Some(event) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn soloist_live_events_are_namespaced_and_filter_private_messages() { + let playback = json!({ + "type": "playback_state", "status": "buffering", "is_active": false, + "item": { "uri": "spotify:track:3stWWPN41byqp8loPdy92u", "decorations": { + "identity": { "name": "Master of None" }, "creators": [], + "playback": { "duration_ms": 192240 } + }} + }); + let event = event_payload(playback).expect("playback is allowed"); + assert_eq!(event["kind"], "playback_state"); + assert_eq!(event["now"]["title"], "Master of None"); + assert_eq!(event["now"]["status"], "buffering"); + assert_eq!(event["now"]["isActive"], false); + let active = event_payload(json!({ + "type": "device_changed", "is_active": true, + "device_name": "Ryotunes Soloist Test" + })) + .expect("active device transition"); + assert_eq!(active["source"]["is_active"], true); + assert!(event_payload(json!({"type":"auth_state", "token":"secret"})).is_none()); + assert!(event_payload(json!({"type":"command_result", "command":"pause"})).is_none()); + } + + #[test] + fn parses_a_realistic_playback_snapshot() { + let raw = json!({ + "type": "playback_state", "status": "playing", "is_active": true, + "item": {"uri": "spotify:track:2X485T9Z5Ly0xyaghN73ed", "decorations": { + "identity": {"name": "Let It Happen"}, + "visual_identity": {"cover": [{"url": "small", "size": "small"}, {"url": "large", "size": "large"}]}, + "parent": {"entity": {"uri": "spotify:album:79dL7FLiJFOO0EoehUHQBv", "decorations": {"identity": {"name": "Currents"}}}}, + "creators": [{"entity": {"decorations": {"identity": {"name": "Tame Impala"}}}}], + "playback": {"duration_ms": 459877} + }}, + "position": {"position_ms": 276807, "timestamp_ms": 1793220359871u64, "speed": 1.0}, + "volume": 100, "available_actions": {"pause": {}} + }); + let now = normalize_now(&raw); + assert_eq!(now["title"], "Let It Happen"); + assert_eq!(now["artists"], "Tame Impala"); + assert_eq!(now["album"], "Currents"); + assert_eq!(now["thumbnail"], "large"); + assert_eq!(now["durationMs"], 459877); + assert_eq!(now["positionMs"], 276807); + assert_eq!(now["paused"], false); + } + + #[test] + fn blocks_unsafe_controls_and_bad_uris() { + assert!(control_messages("sign_out", &Value::Null).is_err()); + assert!(control_messages("play", &json!({"uri":"https://example.com"})).is_err()); + assert!(control_messages( + "add_to_queue", + &json!({"uri":"spotify:playlist:37i9dQZF1EYkqdzj48dyYq"}) + ) + .is_err()); + assert!(control_messages("volume", &json!({"volume": 101})).is_err()); + assert!(control_messages("pause", &Value::Null).is_ok()); + } + + #[test] + fn repeat_mode_emits_two_ordered_commands() { + let v = control_messages("repeat", &json!({"mode":"track"})).unwrap(); + assert_eq!(v[0]["command"], "set_repeat_context"); + assert_eq!(v[1]["command"], "set_repeat_track"); + assert_eq!(v[1]["enabled"], true); + } +} diff --git a/crates/core/src/state.rs b/crates/core/src/state.rs index 3dcec6bf..3a68ff12 100644 --- a/crates/core/src/state.rs +++ b/crates/core/src/state.rs @@ -413,6 +413,58 @@ impl QueueState { } } + +// Persist the complete effect chain, including the enabled flag and all 20 bands. +// This is a private daemon setting, not a UI preference controlled by a webview. +const AUDIO_FX_SETTING_KEY: &str = "audio_fx"; + +fn validate_audio_fx(fx: &AudioFx) -> Result<(), String> { + if !fx.speed.is_finite() || !(0.25..=2.0).contains(&fx.speed) { + return Err("Tempo must be between 0.25× and 2.00×.".into()); + } + if !(-12..=12).contains(&fx.semitones) { + return Err("Pitch must be between -12 and +12 semitones.".into()); + } + if !fx.reverb.is_finite() || !(0.0..=1.0).contains(&fx.reverb) { + return Err("Reverb must be between 0 and 1.".into()); + } + if !fx.bass_db.is_finite() || !(-6.0..=12.0).contains(&fx.bass_db) { + return Err("Bass must be between -6 and +12 dB.".into()); + } + if !fx.width.is_finite() || !(0.0..=1.0).contains(&fx.width) { + return Err("Width must be between 0 and 1.".into()); + } + for (index, band) in fx.eq.bands.iter().enumerate() { + if !band.frequency.is_finite() || !(20.0..=20000.0).contains(&band.frequency) + || !band.q.is_finite() || !(0.3..=10.0).contains(&band.q) + || !band.gain.is_finite() || !(-12.0..=12.0).contains(&band.gain) + { + return Err(format!("Invalid equalizer values in band {}.", index + 1)); + } + } + Ok(()) +} + +// An invalid stored value must not prevent the daemon from starting or leave a +// partially applied filter chain behind. The UI receives the validated mirror. +fn read_saved_audio_fx(db: &Db) -> AudioFx { + let Some(raw) = db.get_setting(AUDIO_FX_SETTING_KEY) else { + return AudioFx::default(); + }; + let fx = match serde_json::from_str::(&raw) { + Ok(fx) => fx, + Err(error) => { + tracing::warn!(%error, "Ignoring malformed saved audio effects"); + return AudioFx::default(); + } + }; + if let Err(reason) = validate_audio_fx(&fx) { + tracing::warn!(%reason, "Ignoring out-of-range saved audio effects"); + return AudioFx::default(); + } + fx +} + impl AppState { #[allow(clippy::too_many_arguments)] pub fn new( @@ -478,6 +530,18 @@ impl AppState { Ok(json) => tidal_db.set_setting(TIDAL_AUTH_KEY, &json), Err(e) => tracing::error!(error = %e, "tidal: cannot serialize rotated auth"), }); + let saved_audio_fx = read_saved_audio_fx(&db); + let live_audio_fx = if saved_audio_fx == AudioFx::default() { + saved_audio_fx + } else { + match player.set_fx(&saved_audio_fx) { + Ok(()) => saved_audio_fx, + Err(error) => { + tracing::warn!(%error, "Could not restore saved audio effects"); + AudioFx::default() + } + } + }; AppState { it, clients, @@ -508,7 +572,7 @@ impl AppState { last_persisted_fingerprint: AtomicU64::new(0), stop_after_current: AtomicBool::new(false), low_resource_mode: AtomicBool::new(low_resource_mode), - audio_fx: parking_lot::RwLock::new(AudioFx::default()), + audio_fx: parking_lot::RwLock::new(live_audio_fx), spotify_stream: parking_lot::Mutex::new(None), } } @@ -4316,23 +4380,12 @@ impl AppState { /// that must not trust a client), applied to the player as one `af` chain, then — only after a /// successful apply — stored and echoed to every client as `audio-fx` so all windows agree. pub fn set_audio_fx(&self, fx: AudioFx) -> Result<(), String> { - if !fx.speed.is_finite() || !(0.25..=2.0).contains(&fx.speed) { - return Err("Tempo must be between 0.25× and 2.00×.".into()); - } - if !(-12..=12).contains(&fx.semitones) { - return Err("Pitch must be between -12 and +12 semitones.".into()); - } - if !fx.reverb.is_finite() || !(0.0..=1.0).contains(&fx.reverb) { - return Err("Reverb must be between 0 and 1.".into()); - } - if !fx.bass_db.is_finite() || !(-6.0..=12.0).contains(&fx.bass_db) { - return Err("Bass must be between -6 and +12 dB.".into()); - } - if !fx.width.is_finite() || !(0.0..=1.0).contains(&fx.width) { - return Err("Width must be between 0 and 1.".into()); - } + validate_audio_fx(&fx)?; self.player.set_fx(&fx).map_err(|e| e.to_string())?; *self.audio_fx.write() = fx; + if let Ok(serialized) = serde_json::to_string(&fx) { + self.db.set_setting(AUDIO_FX_SETTING_KEY, &serialized); + } self.emit("audio-fx", fx); Ok(()) } @@ -4349,6 +4402,26 @@ mod tests { }; use crate::db::Db; + #[test] + fn audio_fx_persistence_round_trip_and_invalid_fallback() { + use super::{read_saved_audio_fx, AUDIO_FX_SETTING_KEY}; + let db = Db::open(std::path::Path::new(":memory:")).unwrap(); + let mut fx = player::AudioFx::default(); + fx.eq.enabled = true; + fx.eq.bands[10].gain = 8.5; + fx.eq.bands[10].q = 1.6; + fx.eq.bands[10].frequency = 1150.0; + db.set_setting(AUDIO_FX_SETTING_KEY, &serde_json::to_string(&fx).unwrap()); + assert_eq!(read_saved_audio_fx(&db), fx); + + db.set_setting(AUDIO_FX_SETTING_KEY, "not json"); + assert_eq!(read_saved_audio_fx(&db), player::AudioFx::default()); + + fx.eq.bands[10].gain = 30.0; + db.set_setting(AUDIO_FX_SETTING_KEY, &serde_json::to_string(&fx).unwrap()); + assert_eq!(read_saved_audio_fx(&db), player::AudioFx::default()); + } + #[test] fn personal_round_trips_and_rejects_bad_blobs() { let db = Db::open(std::path::Path::new(":memory:")).unwrap(); diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index bb364c45..1124eeff 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -62,6 +62,37 @@ fn friendly_error(e: &libmpv2::Error) -> String { } } +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EqBand { + pub frequency: f64, + pub gain: f64, + pub q: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ParametricEq { + pub enabled: bool, + pub bands: [EqBand; 20], +} + +impl Default for ParametricEq { + fn default() -> Self { + const HZ: [f64; 20] = [ + 32.0, 45.0, 64.0, 90.0, 125.0, 180.0, 250.0, 350.0, 500.0, 700.0, + 1000.0, 1400.0, 2000.0, 2800.0, 4000.0, 5600.0, 8000.0, 11000.0, + 15000.0, 19000.0, + ]; + Self { + enabled: false, + bands: std::array::from_fn(|i| EqBand { + frequency: HZ[i], + gain: 0.0, + q: 1.0, + }), + } + } +} + /// The user-tunable audio effects the Sound dialog drives (spec section 7). Applied as one mpv /// `af` chain alongside the loudness gain; `speed` is mpv's `speed` property, not part of the /// chain. Serialized `{speed, semitones, reverb, bass, width}` for the socket — `bass_db` is @@ -79,13 +110,15 @@ pub struct AudioFx { pub bass_db: f64, /// Stereo width 0..1; 0 omits the filter. pub width: f64, + #[serde(default)] + pub eq: ParametricEq, } impl Default for AudioFx { /// Everything off: speed 1×, no pitch shift, no effects — the same filterless path playback /// took before the Sound dialog existed. fn default() -> Self { - AudioFx { speed: 1.0, semitones: 0, reverb: 0.0, bass_db: 0.0, width: 0.0 } + AudioFx { speed: 1.0, semitones: 0, reverb: 0.0, bass_db: 0.0, width: 0.0, eq: ParametricEq::default() } } } @@ -356,6 +389,17 @@ fn af_chain(gain_db: Option, fx: &AudioFx) -> String { if let Some(g) = gain_db { chain.push(format!("lavfi=[volume={g}dB]")); } + if fx.eq.enabled { + for band in &fx.eq.bands { + if band.gain.abs() < 0.0001 { + continue; + } + chain.push(format!( + "lavfi=[equalizer=f={}:t=q:w={}:g={}]", + band.frequency, band.q, band.gain + )); + } + } if fx.semitones != 0 { // Semitones → frequency multiplier (equal temperament). chain.push(format!( @@ -576,7 +620,7 @@ mod tests { /// A convenience constructor: every field but the four under test at its neutral value. fn fx(reverb: f64, bass_db: f64, width: f64, semitones: i32) -> AudioFx { - AudioFx { speed: 1.0, semitones, reverb, bass_db, width } + AudioFx { speed: 1.0, semitones, reverb, bass_db, width, eq: Default::default() } } /// Everything above is string-building; this drives a real libmpv and reads `af` back out of @@ -635,7 +679,7 @@ mod tests { // (set_property would error otherwise — this is the only proof aecho/bass/stereowiden // parse on this build). p.set_gain(None).unwrap(); - p.set_fx(&AudioFx { speed: 1.0, semitones: 0, reverb: 1.0, bass_db: 8.0, width: 0.8 }) + p.set_fx(&AudioFx { speed: 1.0, semitones: 0, reverb: 1.0, bass_db: 8.0, width: 0.8, eq: Default::default() }) .unwrap(); let live = af(); assert!(live.contains("aecho"), "reverb missing from live chain: {live}"); @@ -643,6 +687,20 @@ mod tests { assert!(live.contains("stereowiden"), "width missing from live chain: {live}"); } + #[test] + fn parametric_eq_is_neutral_until_enabled_and_gained() { + let mut fx = AudioFx::default(); + assert_eq!(af_chain(None, &fx), ""); + fx.eq.enabled = true; + assert_eq!(af_chain(None, &fx), ""); + fx.eq.bands[10].gain = 3.0; + let chain = af_chain(Some(-4.0), &fx); + assert!(chain.starts_with("lavfi=[volume=-4dB],")); + assert!(chain.contains("lavfi=[equalizer=f=1000:t=q:w=1:g=3]")); + fx.eq.enabled = false; + assert!(!af_chain(None, &fx).contains("equalizer=")); + } + #[test] fn paths_survive_mpvs_command_parser() { // The bug this exists for: a space used to end the argument. diff --git a/crates/ryotunesd/src/main.rs b/crates/ryotunesd/src/main.rs index 7c5ed6d5..5542c707 100644 --- a/crates/ryotunesd/src/main.rs +++ b/crates/ryotunesd/src/main.rs @@ -61,6 +61,25 @@ fn main() -> anyhow::Result<()> { let (state, events, media_rx, lt_rx) = app::build(paths, sink.clone(), js, login, &tokio::runtime::Handle::current())?; app::spawn_pumps(state.clone(), events, media_rx, lt_rx, sink.clone(), lifecycle.clone()); + + // Optional Soloist event observer. Deliberately namespaced: no MPV state, + // Spotify sign-in state, QML player or existing provider routing is changed. + // Enable only for a manual integration test: RYOTUNES_SOLOIST_EVENTS=1. + if std::env::var("RYOTUNES_SOLOIST_EVENTS").ok().as_deref() == Some("1") { + let observer_state = state.clone(); + tokio::spawn(async move { + let bridge = ryotunes_core::soloist::SoloistBridge::discover(); + loop { + if let Err(error) = bridge + .watch_stream(|event| observer_state.emit("soloist-event", event)) + .await + { + tracing::debug!(%error, "Soloist event stream ended; reconnecting"); + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + }); + } tray::spawn(state.clone(), quit_tx.clone(), sink.clone()); // Restore a cached Spotify session in the background so browsing and playback are ready diff --git a/crates/ryotunesd/src/methods.rs b/crates/ryotunesd/src/methods.rs index e42726ba..08e92019 100644 --- a/crates/ryotunesd/src/methods.rs +++ b/crates/ryotunesd/src/methods.rs @@ -24,6 +24,7 @@ use ryotunes_core::state::{ }; use ryotunes_core::tidal_bridge; use ryotunes_core::{local, radio}; +use ryotunes_core::host::EventSink; use ryotunes_protocol::{ErrorBody, PROTOCOL_VERSION}; use serde::de::DeserializeOwned; use serde_json::{json, Value}; @@ -156,6 +157,11 @@ fn spotify_err(e: impl ToString) -> ErrorBody { ErrorBody { code: "spotify".into(), message: e.to_string() } } +/// Soloist connection errors must not affect the existing Spotify/librespot auth state. +fn soloist_err(e: impl ToString) -> ErrorBody { + ErrorBody { code: "soloist".into(), message: e.to_string() } +} + /// The error a by-prefix Spotify command returns when no session is signed in. fn spotify_signed_out() -> ErrorBody { ErrorBody { code: "spotify_signed_out".into(), message: "Sign in to Spotify".into() } @@ -205,6 +211,7 @@ impl Dispatch for Methods { })) } "quit" => { + conn.sink.emit("quit", Value::Null); let _ = self.quit.send(()); null() } @@ -428,6 +435,29 @@ impl Dispatch for Methods { } "get_provider" => ok(json!({ "provider": st.spotify.selected().as_str() })), "spotify_status" => ok(spotify_status_json(st).await), + // --- Soloist trial bridge (opt-in; does not touch the MPV player) ---------------- + "soloist_status" => { + let bridge = ryotunes_core::soloist::SoloistBridge::discover(); + match bridge.status().await { + Ok(status) => ok(json!({ "available": true, "state": status })), + Err(error) => ok(json!({ "available": false, "reason": error.to_string() })), + } + } + "soloist_now" => { + let bridge = ryotunes_core::soloist::SoloistBridge::discover(); + ok(bridge.now().await.map_err(soloist_err)?) + } + "soloist_queue" => { + let limit = opt_arg::(¶ms, "limit").unwrap_or(10); + let bridge = ryotunes_core::soloist::SoloistBridge::discover(); + ok(bridge.queue(limit).await.map_err(soloist_err)?) + } + "soloist_control" => { + let action = arg::(¶ms, "action")?; + let args = params.get("args").cloned().unwrap_or_else(|| json!({})); + let bridge = ryotunes_core::soloist::SoloistBridge::discover(); + ok(bridge.control(&action, &args).await.map_err(soloist_err)?) + } "soundcloud_status" => ok(soundcloud_status_json(st)), "tidal_status" => ok(tidal_status_json(st)), "tidal_sign_out" => { diff --git a/crates/ryotunesd/src/tray.rs b/crates/ryotunesd/src/tray.rs index 82c5144a..5d820b6c 100644 --- a/crates/ryotunesd/src/tray.rs +++ b/crates/ryotunesd/src/tray.rs @@ -35,6 +35,7 @@ impl RyotunesTray { match id { "show" => show(&self.sink), "quit" => { + self.sink.emit("quit", Value::Null); let _ = self.quit.send(()); } other => { diff --git a/docs/assets/homepage.png b/docs/assets/homepage.png new file mode 100644 index 00000000..86fb050d Binary files /dev/null and b/docs/assets/homepage.png differ diff --git a/docs/assets/ryotunes-hero.svg b/docs/assets/ryotunes-hero.svg deleted file mode 100644 index cd41f06e..00000000 --- a/docs/assets/ryotunes-hero.svg +++ /dev/null @@ -1,65 +0,0 @@ - - Ryotunes — Music, shaped for Ryoku - A restrained Ryoku-inspired banner for the Ryotunes desktop music application. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - RYOKU // MUSIC - RYOTUNES - Music, shaped for the Ryoku desktop. - - - - NATIVE AUDIO - - - LIVE THEMING - - - WEBKIT HIBERNATION - - - - - - - - - - PLAYBACK LIVES ON - UI MAY DISAPPEAR - - - - - - diff --git a/docs/assets/ryotunes-surface.svg b/docs/assets/ryotunes-surface.svg deleted file mode 100644 index 1e0a79f5..00000000 --- a/docs/assets/ryotunes-surface.svg +++ /dev/null @@ -1,135 +0,0 @@ - -Illustrated Ryotunes v2.3 Home surface -A vector artwork based on Ryotunes Home, showing navigation, a listening console, artist cards and the bottom player. - - - - - - - - - - - - - - - - - - - - - - - - - -RYOTUNES -RYOKU // MUSIC - - - - -Ryotunes -LISTEN - - - - - Home - - - Search - - - Library - - -PLAYLISTS - - Liked music - Night drive - On repeat - - - - -Follow System -Ryoku palette live - - -HOME / 08:44 -Good morning -Your listening console, shaped by what is already playing. - - - - -NOT -LIKE -US - -NOW PLAYING -Not Like Us -Kendrick Lamar - - -PAUSE - -QUEUE - - -SESSION -native libmpv -MPRIS connected -Ryoku palette live - - -Familiar artists -SEE ALL - - - - - Juice WRLD - familiar artist - - OPEN - - - - Kendrick Lamar - in rotation - - - - Your mix - continue listening - - - RYOKU THEME - Follow System - - - - - - - - - -Not Like Us -Kendrick Lamar - - - - - - - - - -02:14 / 04:34 - \ No newline at end of file diff --git a/docs/spotify-soloist.md b/docs/spotify-soloist.md new file mode 100644 index 00000000..17163cff --- /dev/null +++ b/docs/spotify-soloist.md @@ -0,0 +1,58 @@ +# Spotify Soloist on NixOS + +RyotunesNix ships the Rust Soloist **bridge**, not the separately distributed +Spotify Soloist executable, credentials, or an automatically enabled backend. +The bridge connects to an already running local WebSocket; without it Spotify +playback through Soloist reports `ws.addr missing`. + +## One-time setup (opt in) + +Obtain your own permitted Soloist executable and Spotify Soloist API key from +their official provider. Then run: + +```bash +nix run 'github:Ryoku-dev/RyotunesNix#setup-soloist' -- /absolute/path/to/soloist +``` + +Alternatively, from a source checkout, run: + +```bash +bash scripts/setup-soloist.sh /absolute/path/to/soloist +``` + +The script stores the key as a user-only file (`~/.config/ryotunes-soloist/api-key`), +creates an opt-in `systemd --user` service, and enables startup at login. +It uses `steam-run-free` via `nix shell` for the external Soloist runtime. +No NixOS rebuild is necessary for this *user-service* setup. + +The service and launcher are deliberately kept outside the immutable Nix store. +The key is never added to the repo or flake lock. Soloist currently receives +its key through `--api-key`, so the key may be inspectable by processes with +sufficient access to command-line arguments. Do not publish process listings +or service debug logs containing credentials. + +The installer preserves an existing user service rather than overwriting it. +It will automatically select the legacy `ryotunes-soloist-eval` directory if +that directory exists, matching Ryotunes' current discovery code. Otherwise +it uses the normal `~/.local/share/soloist` data directory (or `XDG_DATA_HOME`). + +## Verify startup + +```bash +systemctl --user is-enabled ryotunes-soloist-backend.service +systemctl --user is-active ryotunes-soloist-backend.service +``` + +When Soloist starts with `--ws`, Ryotunes looks for `ws.addr` and `ws.port` +in the selected data directory. Playback also requires the separate +`ryotunesd` daemon to be running. These are **two** distinct processes. + +## Limits + +- This setup requires the user to supply their own executable and key; `nix run` + does not fetch or redistribute Soloist. +- The Spotify API and executable may change or require renewed authorization. +- The `ryotunes-soloist-backend` user service can be disabled with + `systemctl --user disable --now ryotunes-soloist-backend.service`. +- The backend and Ryotunes are separate; removing an older Ryotunes package + does **not** require deleting the Soloist backend or its data. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..107ec8d3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,175 @@ +{ + "nodes": { + "bibataMaterialSrc": { + "flake": false, + "locked": { + "lastModified": 1738046050, + "narHash": "sha256-p36pHyoVOcDPm/tbk8YKsL+ItTaVKrGTfQ8zp022mGA=", + "owner": "rtgiskard", + "repo": "bibata_cursor", + "rev": "f4ccfe8abb63fddc7b3ce51a866fd8378395cb3d", + "type": "github" + }, + "original": { + "owner": "rtgiskard", + "repo": "bibata_cursor", + "rev": "f4ccfe8abb63fddc7b3ce51a866fd8378395cb3d", + "type": "github" + } + }, + "hyprglassSrc": { + "flake": false, + "locked": { + "lastModified": 1784579282, + "narHash": "sha256-x/584kY+XXlU/OWKtZAFo89VtowjLXs1DiP9PC0o0Os=", + "owner": "hyprnux", + "repo": "hyprglass", + "rev": "5bc835dcc909cef6980291688143048cf16942b5", + "type": "github" + }, + "original": { + "owner": "hyprnux", + "ref": "v0.7.0", + "repo": "hyprglass", + "type": "github" + } + }, + "imgbordersSrc": { + "flake": false, + "locked": { + "lastModified": 1786876215, + "narHash": "sha256-O+896T2qrisxiWTotB5HlzKw8XEJqPDTgSUHAAVUD18=", + "ref": "master", + "rev": "08be22236144d3c91607bcfa955ed0d457f4f50b", + "revCount": 76, + "type": "git", + "url": "https://codeberg.org/zacoons/imgborders.git" + }, + "original": { + "ref": "master", + "type": "git", + "url": "https://codeberg.org/zacoons/imgborders.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1787498568, + "narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1787736819, + "narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "ryoku": "ryoku" + } + }, + "ryoku": { + "inputs": { + "bibataMaterialSrc": "bibataMaterialSrc", + "hyprglassSrc": "hyprglassSrc", + "imgbordersSrc": "imgbordersSrc", + "nixpkgs": [ + "nixpkgs" + ], + "ryomanagerSrc": "ryomanagerSrc", + "ryotunesSrc": "ryotunesSrc", + "skwdWall": "skwdWall" + }, + "locked": { + "lastModified": 1793223354, + "narHash": "sha256-qUP3qTAGSfbSfHyFcFrGSs1NhtuA8Stuol+wdD4MCY4=", + "owner": "Ryoku-dev", + "repo": "ryoku", + "rev": "1d3feb1b8a6db9ee9ffdc15d0cb626828a5eed4a", + "type": "github" + }, + "original": { + "owner": "Ryoku-dev", + "ref": "unstable-dev", + "repo": "ryoku", + "type": "github" + } + }, + "ryomanagerSrc": { + "flake": false, + "locked": { + "lastModified": 1792184455, + "narHash": "sha256-69+vyEh4V/YqqAGNScnjA0I92qdSyxHeQJqcNIbgMMg=", + "owner": "aethctl", + "repo": "ryomanager", + "rev": "459c6714e31276f73025369e722cf4ab48eccbc3", + "type": "github" + }, + "original": { + "owner": "aethctl", + "repo": "ryomanager", + "rev": "459c6714e31276f73025369e722cf4ab48eccbc3", + "type": "github" + } + }, + "ryotunesSrc": { + "flake": false, + "locked": { + "lastModified": 1791950985, + "narHash": "sha256-pFguZ3ZSQ7DLI9wDukjhUklt54jUOc6mEn5XBVpsolY=", + "owner": "Ryoku-dev", + "repo": "ryotunes", + "rev": "ade86b744651b908f3946b6769853e5084a59ce1", + "type": "github" + }, + "original": { + "owner": "Ryoku-dev", + "repo": "ryotunes", + "rev": "ade86b744651b908f3946b6769853e5084a59ce1", + "type": "github" + } + }, + "skwdWall": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1792102654, + "narHash": "sha256-dNISNxw7+ehLeaA+XBJzUjV24ik6Oibce6C0RxzzZns=", + "owner": "liixini", + "repo": "skwd-wall", + "rev": "d9142ad9e23bf33390e3fea38a5ce45a6fc286da", + "type": "github" + }, + "original": { + "owner": "liixini", + "ref": "nix", + "repo": "skwd-wall", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..73d3a30d --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + description = "RyotunesNix: Ryoku-native music player for NixOS"; + + inputs = { + nixpkgs.url = + "github:NixOS/nixpkgs/56c02bc00adcf003215cc4bd996d6efaf4cff188"; + + ryoku = { + url = "github:Ryoku-dev/ryoku/unstable-dev"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, ryoku, ... }: + let + system = "x86_64-linux"; + + quickshellScreencopyFix = final: prev: { + quickshell = prev.quickshell.overrideAttrs (old: { + patches = (old.patches or [ ]) ++ [ + (prev.fetchurl { + url = "https://github.com/quickshell-mirror/quickshell/commit/851ab0eac6bdfa9201a1a50e7bc3a6d29af25949.patch"; + hash = "sha256-g+8NQeFyGK9v76cPM9ogkMSfLH0SGPOyREFkTf6MJ7I="; + }) + ]; + }); + }; + + pkgs = import nixpkgs { + inherit system; + overlays = [ quickshellScreencopyFix ]; + }; + + ryotunesUnwrapped = pkgs.callPackage ./nix/package.nix { + src = self.outPath; + }; + + ryotunes = pkgs.callPackage ./nix/wrapper.nix { + inherit ryotunesUnwrapped; + ryokuQml = ryoku.packages.${system}.ryoku-qml; + }; + + # Optional local setup; never packages the external Soloist binary/key. + soloistSetup = pkgs.writeShellApplication { + name = "ryotunes-soloist-setup"; + runtimeInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.nix pkgs.systemd ]; + text = builtins.readFile ./scripts/setup-soloist.sh; + }; + in + { + packages.${system} = { + inherit ryotunes; + ryotunes-unwrapped = ryotunesUnwrapped; + soloist-setup = soloistSetup; + default = ryotunes; + }; + + apps.${system} = { + default = { + type = "app"; + program = "${ryotunes}/bin/ryotunes"; + }; + + setup-soloist = { + type = "app"; + program = "${soloistSetup}/bin/ryotunes-soloist-setup"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..cedcfa4e --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,133 @@ +{ + lib, + rustPlatform, + pkg-config, + wrapGAppsHook3, + glib, + gtk3, + gdk-pixbuf, + cairo, + pango, + webkitgtk_4_1, + libsoup_3, + mpv, + libappindicator-gtk3, + libayatana-appindicator, + openssl, + librsvg, + src, +}: + +rustPlatform.buildRustPackage rec { + pname = "ryotunesnix-unwrapped"; + version = "1.2.3"; + + inherit src; + + cargoLock = { + lockFile = ../Cargo.lock; + allowBuiltinFetchGit = true; + }; + + nativeBuildInputs = [ + pkg-config + wrapGAppsHook3 + rustPlatform.bindgenHook + ]; + + buildInputs = [ + glib + gtk3 + gdk-pixbuf + cairo + pango + webkitgtk_4_1 + libsoup_3 + mpv + libappindicator-gtk3 + libayatana-appindicator + openssl + librsvg + ]; + + preBuild = '' + export HOME="$TMPDIR" + ''; + + cargoBuildFlags = [ + "--package" "sync-server" + "--package" "ryotunesd" + "--package" "ryotunes-cli" + ]; + + doCheck = false; + + installPhase = '' + runHook preInstall + + install_bin() { + local name="$1" + local binary + binary="$(find target -type f -path "*/release/$name" -perm -0100 -print -quit)" + if [ -z "$binary" ]; then + printf 'Ryotunes binary not built: %s\n' "$name" >&2 + exit 1 + fi + install -Dm755 "$binary" "$out/bin/$name" + } + + install_bin ryotunes-sync + install_bin ryotunesd + install_bin ryotunes-cli + + mkdir -p \ + "$out/share/ryotunes/client" \ + "$out/share/ryotunes/skins" \ + "$out/share/ryotunes/matugen" \ + "$out/share/applications" \ + "$out/share/metainfo" \ + "$out/share/licenses/ryotunes" \ + "$out/share/doc/ryotunes" + + cp -r client/. "$out/share/ryotunes/client/" + rm -rf "$out/share/ryotunes/client/tests" + cp -r skins/. "$out/share/ryotunes/skins/" + + find "$out/share/ryotunes/client" "$out/share/ryotunes/skins" \ + -type d -exec chmod 755 {} + + find "$out/share/ryotunes/client" "$out/share/ryotunes/skins" \ + -type f -exec chmod 644 {} + + + install -Dm644 matugen/ryotunes.json \ + "$out/share/ryotunes/matugen/ryotunes.json" + install -Dm644 packaging/linux/ryotunes.desktop \ + "$out/share/applications/ryotunes.desktop" + install -Dm644 packaging/linux/dev.ryoku.ryotunes.metainfo.xml \ + "$out/share/metainfo/dev.ryoku.ryotunes.metainfo.xml" + + for size in 32 64 128; do + install -Dm644 "src-tauri/icons/''${size}x''${size}.png" \ + "$out/share/icons/hicolor/''${size}x''${size}/apps/ryotunes.png" + done + install -Dm644 src-tauri/icons/128x128@2x.png \ + "$out/share/icons/hicolor/256x256/apps/ryotunes.png" + install -Dm644 src-tauri/icons/icon.png \ + "$out/share/icons/hicolor/512x512/apps/ryotunes.png" + + install -Dm755 scripts/diagnostics.sh \ + "$out/bin/ryotunes-diagnostics" + install -Dm644 LICENSE "$out/share/licenses/ryotunes/LICENSE" + install -Dm644 README.md "$out/share/doc/ryotunes/README.md" + install -Dm644 UPSTREAM.md "$out/share/doc/ryotunes/UPSTREAM.md" + + runHook postInstall + ''; + + meta = { + description = "Ryotunes playback daemon, CLI and native Quickshell client"; + homepage = "https://github.com/Ryoku-dev/RyotunesNix"; + license = lib.licenses.gpl3Plus; + platforms = [ "x86_64-linux" ]; + mainProgram = "ryotunes"; + }; +} diff --git a/nix/wrapper.nix b/nix/wrapper.nix new file mode 100644 index 00000000..abd0b98b --- /dev/null +++ b/nix/wrapper.nix @@ -0,0 +1,58 @@ +{ + lib, + symlinkJoin, + makeWrapper, + quickshell, + qt6, + kdePackages, + python3, + ffmpeg, + pulseaudio, + xdg-utils, + ryotunesUnwrapped, + ryokuQml, +}: + +let + qmlPackages = [ + ryokuQml + qt6.qtdeclarative + qt6.qtmultimedia + qt6.qtwayland + qt6.qt5compat + qt6.qtsvg + qt6.qtimageformats + kdePackages.kirigami.unwrapped + ]; + + qmlImports = lib.makeSearchPath "lib/qt-6/qml" qmlPackages; + qtPlugins = lib.makeSearchPath "lib/qt-6/plugins" qmlPackages; + runtimePath = lib.makeBinPath [ python3 ffmpeg pulseaudio xdg-utils ]; +in +symlinkJoin { + name = "ryotunesnix-${ryotunesUnwrapped.version}"; + paths = [ ryotunesUnwrapped ]; + nativeBuildInputs = [ makeWrapper ]; + preferLocalBuild = false; + allowSubstitutes = true; + + postBuild = '' + # The desktop entry and default flake app must launch native Quickshell. + makeWrapper ${quickshell}/bin/qs "$out/bin/ryotunes" \ + --add-flags "-p ${ryotunesUnwrapped}/share/ryotunes/client" \ + --prefix QML_IMPORT_PATH : "${qmlImports}" \ + --prefix QML2_IMPORT_PATH : "${qmlImports}" \ + --prefix QT_PLUGIN_PATH : "${qtPlugins}" \ + --prefix PATH : "${runtimePath}" + + ln -s ryotunes "$out/bin/ryotunes-qml" + + rm "$out/bin/ryotunesd" + makeWrapper ${ryotunesUnwrapped}/bin/ryotunesd "$out/bin/ryotunesd" \ + --prefix PATH : "$out/bin:${runtimePath}" + ''; + + meta = ryotunesUnwrapped.meta // { + description = "Ryotunes with native Ryoku QML integration"; + }; +} diff --git a/scripts/setup-soloist.sh b/scripts/setup-soloist.sh new file mode 100755 index 00000000..4acc336e --- /dev/null +++ b/scripts/setup-soloist.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Explicit opt-in setup for the separately acquired Spotify Soloist backend. +# Never downloads Soloist, commits credentials, or changes the Ryotunes daemon. +set -euo pipefail +umask 077 + +if [[ ${1:-} == --help || $# -gt 1 ]]; then + printf 'Usage: ryotunes-soloist-setup [absolute-path-to-soloist]\n' + printf 'Soloist is an external runtime. Supply your own executable and API key.\n' + exit 0 +fi + +home=${HOME:?HOME is required} +config_dir="$home/.config/ryotunes-soloist" +key_file="$config_dir/api-key" +unit_dir="$home/.config/systemd/user" +unit_file="$unit_dir/ryotunes-soloist-backend.service" +launcher_dir="$home/.local/bin" +launcher="$launcher_dir/ryotunes-soloist-backend" + +if [[ -e "$unit_file" ]] && ! grep -Fq '# Managed by RyotunesNix Soloist setup' "$unit_file"; then + printf 'Existing Soloist user service detected; leaving it unchanged: %s\n' "$unit_file" + printf 'Run: systemctl --user status ryotunes-soloist-backend.service\n' + exit 0 +fi + +binary=${1:-${RYOTUNES_SOLOIST_BINARY:-}} +if [[ -z $binary ]]; then + for candidate in \ + "$home/.local/opt/ryotunes-soloist/soloist" \ + "$home/.local/opt/ryotunes-soloist-eval/soloist"; do + if [[ -x $candidate ]]; then + binary=$candidate + break + fi + done +fi + +if [[ -z $binary || ! -x $binary ]]; then + printf 'Missing Soloist executable. Supply an executable path:\n' >&2 + printf ' nix run github:Ryoku-dev/RyotunesNix#setup-soloist -- /absolute/path/to/soloist\n' >&2 + exit 1 +fi + +binary=$(realpath -- "$binary") +if [[ ${binary:0:1} != / ]]; then + printf 'Soloist executable must use an absolute path.\n' >&2 + exit 1 +fi + +if [[ -n ${RYOTUNES_SOLOIST_DATA_DIR:-} ]]; then + data_dir=$RYOTUNES_SOLOIST_DATA_DIR +elif [[ -d "$home/.local/share/ryotunes-soloist-eval" ]]; then + # Match the Ryotunes bridge's legacy evaluation-data discovery rule. + data_dir="$home/.local/share/ryotunes-soloist-eval" +else + data_dir="${XDG_DATA_HOME:-$home/.local/share}/soloist" +fi + +mkdir -p -- "$config_dir" "$data_dir" "$launcher_dir" "$unit_dir" +chmod 700 "$config_dir" "$data_dir" + +if [[ ! -s "$key_file" ]]; then + if [[ ! -r /dev/tty ]]; then + printf 'An interactive terminal is needed to enter the Soloist API key.\n' >&2 + exit 1 + fi + IFS= read -r -s -p 'Spotify Soloist API key: ' secret < /dev/tty + printf '\n' + if [[ -z $secret ]]; then + printf 'Empty API key. Setup stopped.\n' >&2 + exit 1 + fi + printf '%s' "$secret" > "$key_file" + unset secret +fi +chmod 600 "$key_file" + +printf '%s\n' "$binary" > "$config_dir/backend-path" +printf '%s\n' "$data_dir" > "$config_dir/data-dir" +chmod 600 "$config_dir/backend-path" "$config_dir/data-dir" + +cat > "$launcher" <<'LAUNCHER' +#!/usr/bin/env bash +set -euo pipefail + +config="${HOME:?}/.config/ryotunes-soloist" +backend="$(<"$config/backend-path")" +data="$(<"$config/data-dir")" +key="$config/api-key" + +if [[ ! -x "$backend" || ! -s "$key" ]]; then + printf 'Soloist executable or private API key is unavailable.\n' >&2 + exit 1 +fi + +export PATH="/run/current-system/sw/bin:${PATH:-/usr/bin:/bin}" +exec nix shell nixpkgs#steam-run-free -c steam-run \ + "$backend" \ + --device-name Ryotunes \ + --api-key "$(<"$key")" \ + --data-dir "$data" \ + --ws 127.0.0.1:0 +LAUNCHER +chmod 700 "$launcher" + +cat > "$unit_file" <<'UNIT' +# Managed by RyotunesNix Soloist setup +[Unit] +Description=Spotify Soloist backend for Ryotunes +After=pipewire.service wireplumber.service + +[Service] +Type=simple +ExecStart=%h/.local/bin/ryotunes-soloist-backend +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +UNIT +chmod 600 "$unit_file" + +systemctl --user daemon-reload +systemctl --user enable --now ryotunes-soloist-backend.service + +# An already-active managed service needs a restart to pick up changed paths. +if systemctl --user is-active --quiet ryotunes-soloist-backend.service; then + systemctl --user restart ryotunes-soloist-backend.service +fi + +for ((attempt = 0; attempt < 30; attempt++)); do + if [[ -s "$data_dir/ws.addr" && -s "$data_dir/ws.port" ]] && + systemctl --user is-active --quiet ryotunes-soloist-backend.service; then + printf 'Soloist WebSocket ready at %s:%s\n' \ + "$(<"$data_dir/ws.addr")" "$(<"$data_dir/ws.port")" + printf 'Enabled user service: ryotunes-soloist-backend.service\n' + exit 0 + fi + sleep 1 +done + +printf 'Soloist endpoint not ready. Review the service without sharing credentials:\n' >&2 +printf ' systemctl --user status ryotunes-soloist-backend.service\n' >&2 +exit 1