diff --git a/.claude/settings.json b/.claude/settings.json index 90308884..f6e977ad 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "enabledPlugins": { - "frontend-design@claude-plugins-official": true + "frontend-design@claude-plugins-official": true, + "expo@claude-plugins-official": true } } diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml new file mode 100644 index 00000000..bfb1df17 --- /dev/null +++ b/.github/workflows/core.yml @@ -0,0 +1,78 @@ +name: Core + +# packages/core (@truetunes/core): typecheck, lint (including the platform-neutral import ban) and Node tests. +on: + push: + branches: [main] + paths: + - 'packages/core/**' + - 'apps/mobile/src/types/globals.d.ts' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/core.yml' + pull_request: + branches: [main, develop] + paths: + - 'packages/core/**' + - 'apps/mobile/src/types/globals.d.ts' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/core.yml' + +permissions: + contents: read + checks: write + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + +defaults: + run: + working-directory: packages/core + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - run: npm ci + working-directory: . + + - run: npm run lint + + - run: npm run typecheck + + - name: Test with coverage + run: > + npm run test:run -- + --reporter=default + --reporter=junit + --outputFile.junit=./junit.xml + --coverage + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: Core Vitest Tests + path: packages/core/junit.xml + reporter: java-junit + fail-on-error: true + + - name: Publish coverage report + uses: davelosert/vitest-coverage-report-action@v2 + if: always() + with: + name: Core + json-summary-path: packages/core/coverage/coverage-summary.json + json-final-path: packages/core/coverage/coverage-final.json + vite-config-path: packages/core/vitest.config.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/desktop.yml similarity index 59% rename from .github/workflows/publish.yml rename to .github/workflows/desktop.yml index 54a6b112..9340a15e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/desktop.yml @@ -1,10 +1,26 @@ -name: Publish +name: Desktop +# Validates desktop PRs; on push to main, bumps the patch version and publishes a desktop release. +# Only desktop paths trigger it: without the filter, every mobile commit to main would publish a desktop release. +# PRs also run it for server/ changes, because the server is linted with the desktop ESLint config. on: + # Publishing is narrower than validation: the root lock and packages/ change with mobile work too, and each + # push here ships a release. Desktop dependency changes always touch apps/desktop/package.json. Add packages/** + # once the desktop imports @truetunes/core. push: branches: [main] + paths: + - 'apps/desktop/**' + - '.github/workflows/desktop.yml' pull_request: branches: [main, develop] + paths: + - 'apps/desktop/**' + - 'packages/**' + - 'server/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/desktop.yml' permissions: contents: write @@ -14,6 +30,10 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +defaults: + run: + working-directory: apps/desktop + jobs: validate: runs-on: ubuntu-latest @@ -27,9 +47,13 @@ jobs: cache: 'npm' - run: npm ci + working-directory: . - run: npm run lint + - run: npm run lint:server + working-directory: . + - run: npm run typecheck - name: Test with coverage @@ -45,7 +69,7 @@ jobs: if: always() with: name: Vitest Tests - path: renderer/junit.xml + path: apps/desktop/renderer/junit.xml reporter: java-junit fail-on-error: true @@ -53,9 +77,10 @@ jobs: uses: davelosert/vitest-coverage-report-action@v2 if: always() with: - json-summary-path: renderer/coverage/coverage-summary.json - json-final-path: renderer/coverage/coverage-final.json - vite-config-path: renderer/vitest.config.ts + name: Desktop + json-summary-path: apps/desktop/renderer/coverage/coverage-summary.json + json-final-path: apps/desktop/renderer/coverage/coverage-final.json + vite-config-path: apps/desktop/renderer/vitest.config.ts version: needs: validate @@ -72,18 +97,21 @@ jobs: with: node-version-file: '.nvmrc' + # --package-lock-only: bump apps/desktop/package.json and its entry in the root lock without installing anything. - name: Bump patch version id: bump + working-directory: . run: | - npm version patch --no-git-tag-version - echo "new_version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + npm version patch -w apps/desktop --no-git-tag-version --package-lock-only + echo "new_version=$(node -p "require('./apps/desktop/package.json').version")" >> $GITHUB_OUTPUT - name: Commit and push id: push + working-directory: . run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json + git add apps/desktop/package.json package-lock.json git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}" git push echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT @@ -112,7 +140,9 @@ jobs: cache: 'npm' - run: npm ci + working-directory: . + # electron-builder runs from apps/desktop (output in apps/desktop/release) and finds the hoisted node_modules itself. - run: npm run ${{ matrix.script }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 00000000..b58fbbd0 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,82 @@ +name: Mobile CI + +on: + push: + branches: [main] + paths: + - 'apps/mobile/**' + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/mobile-ci.yml' + pull_request: + branches: [main, develop] + paths: + - 'apps/mobile/**' + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/mobile-ci.yml' + +permissions: + contents: read + checks: write + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # The root npm ci installs the desktop workspace too; mobile never needs the Electron binary. + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + +defaults: + run: + working-directory: apps/mobile + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: package-lock.json + + - run: npm ci + working-directory: . + + - run: npm run lint + + - run: npm run typecheck + + # Catches Expo SDK / native dependency mismatches before they become a broken build. + - run: npx expo-doctor + + - name: Test with coverage + run: > + npm run test:run -- + --reporter=default + --reporter=junit + --outputFile.junit=./junit.xml + --coverage + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: Mobile Vitest Tests + path: apps/mobile/junit.xml + reporter: java-junit + fail-on-error: true + + - name: Publish coverage report + uses: davelosert/vitest-coverage-report-action@v2 + if: always() + with: + name: Mobile + json-summary-path: apps/mobile/coverage/coverage-summary.json + json-final-path: apps/mobile/coverage/coverage-final.json + vite-config-path: apps/mobile/vitest.config.ts diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 00000000..23c20d3d --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,164 @@ +name: Mobile Release + +# Publishes a signed APK as a GitHub Release when an Android version tag is pushed: +# git tag android-v1.2.3 && git push origin android-v1.2.3 (android-v1.2.3-rc.1 becomes a pre-release) +# A manual run (workflow_dispatch) is a dry run: it builds and uploads the APK as a workflow artifact only. +# Plain v* tags belong to the desktop app. The APK release is never marked "Latest": desktop auto-update +# reads /releases/latest and must keep finding a desktop release there. See apps/mobile/docs/RELEASING.md. + +on: + push: + tags: ['android-v[0-9]+.[0-9]+.[0-9]+*'] + workflow_dispatch: + inputs: + version: + description: 'Version to build, e.g. 1.2.3 (dry run: artifact only, no release)' + required: true + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # The root npm ci installs the desktop workspace too; mobile never needs the Electron binary. + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + +defaults: + run: + working-directory: apps/mobile + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: v + run: | + if [ "${{ github.event_name }}" = "push" ]; then RAW="${GITHUB_REF_NAME#android-v}"; else RAW="${{ inputs.version }}"; fi + if ! echo "$RAW" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::'$RAW' is not a version like 1.2.3 or 1.2.3-rc.1"; exit 1 + fi + BASE="${RAW%%-*}" + IFS=. read -r MAJ MIN PAT <<< "$BASE" + # versionCode must only ever go up. 1.2.3 -> 1020399; a pre-release 1.2.3-rc.N -> 10203NN (below the final). + if [ "$RAW" = "$BASE" ]; then BUILD=99; PRERELEASE=false; else BUILD="$(echo "${RAW#*-}" | grep -Eo '[0-9]+$' | tail -1)"; BUILD="${BUILD:-0}"; PRERELEASE=true; fi + if [ "$BUILD" -gt 98 ] && [ "$PRERELEASE" = true ]; then echo "::error::pre-release number must be below 99"; exit 1; fi + CODE=$((MAJ * 1000000 + MIN * 10000 + PAT * 100 + BUILD)) + echo "version=$RAW" >> "$GITHUB_OUTPUT" + echo "code=$CODE" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Building $RAW (versionCode $CODE, prerelease=$PRERELEASE)" + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: package-lock.json + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - run: npm ci + working-directory: . + + # Cheap checks first so a broken tag fails in minutes, not after the long native build. + - run: npm run typecheck + + - run: npm run test:run + + - name: Stamp version into app.json + run: | + node -e " + const fs = require('fs'); + const a = JSON.parse(fs.readFileSync('app.json', 'utf8')); + a.expo.version = '${{ steps.v.outputs.version }}'; + a.expo.android = { ...a.expo.android, versionCode: ${{ steps.v.outputs.code }} }; + fs.writeFileSync('app.json', JSON.stringify(a, null, 2) + '\n'); + " + + - name: Install Android SDK components + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER" --licenses > /dev/null || true + "$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0" "ndk;27.1.12297006" "cmake;3.22.1" > /dev/null + + - name: Restore release keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + for v in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do + if [ -z "${!v}" ]; then echo "::error::the ANDROID_$v secret is not set"; exit 1; fi + done + echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + { + echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/release.jks" + echo "ANDROID_KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD" + echo "ANDROID_KEY_ALIAS=$KEY_ALIAS" + echo "ANDROID_KEY_PASSWORD=$KEY_PASSWORD" + } >> "$GITHUB_ENV" + + - uses: gradle/actions/setup-gradle@v4 + + - name: Generate the native project + run: npx expo prebuild --platform android --no-install + + - name: Build release APK + working-directory: apps/mobile/android + env: + # Optional integrations, baked into the bundle. Empty (feature off) unless the secrets exist. + EXPO_PUBLIC_GENIUS_ACCESS_TOKEN: ${{ secrets.GENIUS_ACCESS_TOKEN }} + EXPO_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} + run: ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a + + - name: Verify the APK is signed with the release key + run: | + APK=android/app/build/outputs/apk/release/app-release.apk + "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --print-certs "$APK" | tee certs.txt + GOT="$(grep -m1 'certificate SHA-256 digest' certs.txt | sed 's/.*: //' | tr -d ':' | tr 'A-F' 'a-f')" + WANT="$(echo '${{ vars.ANDROID_CERT_SHA256 }}' | tr -d ':' | tr 'A-F' 'a-f')" + if [ -z "$WANT" ]; then echo "::error::ANDROID_CERT_SHA256 repository variable is not set"; exit 1; fi + if [ "$GOT" != "$WANT" ]; then echo "::error::APK is signed with $GOT, expected $WANT"; exit 1; fi + echo "Signed with the expected release key ($GOT)" + + - name: Name and checksum + run: | + NAME="TrueTunes-Android-v${{ steps.v.outputs.version }}.apk" + cp android/app/build/outputs/apk/release/app-release.apk "$NAME" + sha256sum "$NAME" > "$NAME.sha256" + echo "APK_NAME=$NAME" >> "$GITHUB_ENV" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.APK_NAME }} + path: | + apps/mobile/${{ env.APK_NAME }} + apps/mobile/${{ env.APK_NAME }}.sha256 + retention-days: 14 + + - name: Create GitHub release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + FLAGS="" + if [ "${{ steps.v.outputs.prerelease }}" = "true" ]; then FLAGS="--prerelease"; fi + # Desktop releases share this repo, so start the notes at the previous Android tag, not the previous release. + PREV="$(git ls-remote --tags --refs origin 'android-v*' | sed 's#.*refs/tags/##' | grep -vx "$GITHUB_REF_NAME" | sort -V | tail -1 || true)" + if [ -n "$PREV" ]; then FLAGS="$FLAGS --notes-start-tag $PREV"; fi + # --latest=false: an APK release must never become "Latest", or desktop auto-update breaks. + gh release create "$GITHUB_REF_NAME" "$APK_NAME" "$APK_NAME.sha256" \ + --title "True Tunes Android v${{ steps.v.outputs.version }}" \ + --generate-notes --latest=false $FLAGS diff --git a/.gitignore b/.gitignore index 5e70c8c4..b4c58623 100644 --- a/.gitignore +++ b/.gitignore @@ -146,9 +146,10 @@ vite.config.ts.timestamp-* release/ # Native addon build output (node-gyp generated project files + binaries) -native/smtc/build/ +apps/desktop/native/smtc/build/ # Claude Code per-user local settings — may hold session-generated allowlist # entries (incl. credential-reading commands); must never be committed. .claude/settings.local.json server/dist-test/ +junit.xml diff --git a/CLAUDE.md b/CLAUDE.md index abec1086..85a80de1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,40 @@ # Claude Notes — True Tunes 2 +## Repository layout + +This repo is **public** and is an **npm-workspaces monorepo** (`apps/*`, `packages/*`): + +| Path | What | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/desktop` | The desktop Electron app — the rest of this file. **Paths below are relative to `apps/desktop`** | +| `apps/mobile` | The Android/iOS React Native app (moved in from `TrueNorthIT/TrueTunes-Android` with its history). Read `apps/mobile/CLAUDE.md` before touching it | +| `packages/core` | `@truetunes/core`: platform-neutral Sonos/Azure/session modules (TypeScript source, no build). Used by mobile; **the desktop does not use it yet** | +| `server/` | Azure Functions backend. **Not a workspace**: own `package.json` and lockfile, because its deploy uploads `server/node_modules` | + +- One `npm install` at the root installs both apps; there is one root `package-lock.json`. Never add a lockfile under + `apps/`. +- The root `overrides` pin `react`/`react-dom` to the version Expo SDK 57 requires, plus `@tanstack/react-query` and + `@types/react`, so each has exactly one copy. Change them together with the Expo SDK. +- Each app has its own ESLint config and scripts. At the root, `npm run lint` / `typecheck` / `test:run` run every + workspace; `npm run lint:server` lints `server/` with the desktop ESLint config. +- `electron` is pinned to an exact version in `apps/desktop/package.json`: electron-builder cannot see the hoisted + `electron` package and falls back to the declared version. Bump it deliberately. +- The `.env` stays at the repo root (the server deploy reads it); the desktop `build:main` reads it from there. + +Workflows (`.github/workflows/`): + +| File | What | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `desktop.yml` | Desktop PR validation; on push to `main`, bumps `apps/desktop`'s patch version and publishes a release | +| `mobile-ci.yml` | Mobile lint, typecheck, expo-doctor and tests, for changes under `apps/mobile` | +| `core.yml` | `packages/core` lint (incl. the React Native/Expo import ban), typecheck and Node tests | +| `mobile-release.yml` | On an `android-vX.Y.Z` tag, builds and signs the APK and creates a release with `--latest=false` | + +- `desktop.yml` only runs for `apps/desktop/**`, `packages/**` and the root package files (PRs also for `server/**`). + **Keep it that way**: otherwise every mobile commit to `main` publishes a desktop release. +- Tags: `vX.Y.Z` is desktop, `android-vX.Y.Z` is mobile. Desktop auto-update (electron-updater, GitHub provider) reads + `/releases/latest`, so a mobile release must never be marked Latest. + ## What this is Electron desktop Sonos client. Three distinct environments that compile and run separately: @@ -14,6 +49,8 @@ The renderer never imports from `src/` and vice versa. Communication is strictly ## Running and building +Run these from `apps/desktop` (or add `-w apps/desktop` at the root): + ```bash npm run dev # build main + start Vite + Electron concurrently npm run build:main # tsc only (main process) @@ -62,7 +99,7 @@ After making UI changes: ### Sonos API -All Sonos HTTP calls go through `src/main.ts` → `sonosFetch()` → play.sonos.com BFF. The renderer calls `window.sonos.fetch({ operationId, pathParams, query, body })` — operationIds are defined in `SonosWebPlayerAPI.yaml` at the repo root. +All Sonos HTTP calls go through `src/main.ts` → `sonosFetch()` → play.sonos.com BFF. The renderer calls `window.sonos.fetch({ operationId, pathParams, query, body })` — operationIds are defined in `SonosWebPlayerAPI.yaml` in `apps/desktop`. `renderer/src/lib/sonosApi.ts` is the typed wrapper around `window.sonos.fetch` — add new API calls here. @@ -145,7 +182,7 @@ Main process (`src/`), IPC handlers, WebSocket bootstrap, auth flow, and most ho ## Gotchas - **`globals.d.ts` must stay ambient** — no `export {}`, no `import`. Adding either makes it a module and breaks global type resolution silently in the renderer. -- **Renderer typecheck is separate** — `tsc` at the root only checks `src/`. Always run `npm run typecheck` (which checks both) not just `tsc`. +- **Renderer typecheck is separate** — `tsc` in `apps/desktop` only checks `src/`. Always run `npm run typecheck` (which checks both) not just `tsc`. - **`applyReorderLocally` index math** — `insertAt` is the count of non-selected items whose _original_ index is less than `toIndex`, not `toIndex` itself. See `queueHelpers.ts` for comments. - **Image cache is a singleton module** — tests that import `imageCache` must use `vi.resetModules()` and dynamic `import()` in `beforeEach` to avoid state leaking between tests. - **Sandbox mode** — both `uiWin` and `miniWin` have `sandbox: true`. The preload script runs in the sandboxed context; do not use Node.js APIs directly in `preload.ts`. diff --git a/README.md b/README.md index f1564499..05f15bfb 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,19 @@ On first launch you will be prompted to sign in with your Sonos account. After s ```bash git clone https://github.com/TrueNorthIT/TrueTunes -cd true-tunes-2 -npm install -npm run dev +cd TrueTunes +npm install # one install at the root for every workspace +npm run desktop # same as: cd apps/desktop && npm run dev ``` -`npm run dev` builds the main process then starts the Vite dev server and Electron concurrently. Hot module replacement works in the renderer; main process changes require restarting. +The repo is an npm-workspaces monorepo: the desktop app is in `apps/desktop`, the Android/iOS app in `apps/mobile`, and +`server/` is a separate package with its own lockfile. Run desktop scripts from `apps/desktop` (or with `-w apps/desktop` +from the root). `npm run desktop` builds the main process then starts the Vite dev server and Electron concurrently. Hot module replacement works in the renderer; main process changes require restarting. ### Environment variables (optional) -Create a `.env` file in the repo root (it is gitignored): +Create a `.env` file in the repo root (it is gitignored; `server/scripts/deploy.mjs` reads it too). The desktop build +bakes the values into `dist/build-env.json`, so `npm run dev` picks them up: ``` APPINSIGHTS_CONNECTION_STRING=InstrumentationKey=... @@ -56,6 +59,7 @@ Without this the app works normally — telemetry is a silent no-op. ## Project structure ``` +apps/desktop/ Desktop Electron app (paths below are relative to it) src/ Main process (Electron, Node.js) main.ts Entry point — BrowserWindow, IPC handlers, WebSocket, auth preload.ts contextBridge — exposes window.sonos API to renderer @@ -72,6 +76,9 @@ renderer/src/ Renderer process (React + Vite) types/ Ambient type declarations (globals.d.ts — window.sonos shape) test/setup.ts Vitest global setup — mocks window.sonos +apps/mobile/ React Native (Expo) Android/iOS app — see apps/mobile/CLAUDE.md +packages/core/ @truetunes/core — platform-neutral Sonos/Azure/session modules (used by mobile) + server/ Azure Functions (office presence backend) src/functions/ connect.ts Web PubSub connection negotiation @@ -80,25 +87,31 @@ server/ Azure Functions (office presence backend) azuredeploy.json ARM template — deploys all Azure infrastructure .github/workflows/ - publish.yml CI — validate (lint + typecheck + tests) → version bump → publish + desktop.yml CI — validate (lint + typecheck + tests) → version bump → publish (desktop paths only) + mobile-ci.yml Mobile app CI (apps/mobile) + core.yml packages/core lint, typecheck and tests + mobile-release.yml Signed APK release on an android-vX.Y.Z tag ``` --- ## Scripts -| Command | What it does | -|---|---| -| `npm run dev` | Start Electron app in dev mode | -| `npm run build` | Build main process + renderer | -| `npm test` | Run tests in watch mode | -| `npm run test:run` | Run tests once (used in CI) | -| `npm run typecheck` | Type-check both main and renderer | -| `npm run lint` | ESLint | -| `npm run format` | Prettier | -| `npm run electron:build` | Package for current platform | -| `npm run electron:build:win` | Package for Windows | -| `npm run electron:build:mac` | Package for macOS | +Run these from `apps/desktop`. At the root, `npm run lint`, `npm run typecheck` and `npm run test:run` run every +workspace (lint also covers `server/`). + +| Command | What it does | +| ---------------------------- | --------------------------------- | +| `npm run dev` | Start Electron app in dev mode | +| `npm run build` | Build main process + renderer | +| `npm test` | Run tests in watch mode | +| `npm run test:run` | Run tests once (used in CI) | +| `npm run typecheck` | Type-check both main and renderer | +| `npm run lint` | ESLint | +| `npm run format` | Prettier | +| `npm run electron:build` | Package for current platform | +| `npm run electron:build:win` | Package for Windows | +| `npm run electron:build:mac` | Package for macOS | --- @@ -114,6 +127,7 @@ az deployment group create \ ``` Resources created: + - **Azure Function App** — presence/attribution backend - **Azure Web PubSub** — real-time office presence events - **Cosmos DB** — stores queue event history @@ -129,7 +143,7 @@ Every PR to `main` or `develop` runs lint, typecheck, and tests. Merging to `mai The publish job requires these GitHub Actions secrets: -| Secret | Description | -|---|---| -| `GITHUB_TOKEN` | Auto-provided by Actions | +| Secret | Description | +| ------------------------------- | ------------------------ | +| `GITHUB_TOKEN` | Auto-provided by Actions | | `APPINSIGHTS_CONNECTION_STRING` | From ARM template output | diff --git a/.prettierignore b/apps/desktop/.prettierignore similarity index 54% rename from .prettierignore rename to apps/desktop/.prettierignore index 7ec81af1..78548700 100644 --- a/.prettierignore +++ b/apps/desktop/.prettierignore @@ -2,3 +2,6 @@ dist/ renderer/dist/ node_modules/ src/*.html +coverage/ +release/ +native/smtc/build/ diff --git a/SonosWebPlayerAPI.yaml b/apps/desktop/SonosWebPlayerAPI.yaml similarity index 100% rename from SonosWebPlayerAPI.yaml rename to apps/desktop/SonosWebPlayerAPI.yaml diff --git a/assets/AlgorithWhiperer-notext.png b/apps/desktop/assets/AlgorithWhiperer-notext.png similarity index 100% rename from assets/AlgorithWhiperer-notext.png rename to apps/desktop/assets/AlgorithWhiperer-notext.png diff --git a/assets/AlgorithmWhisperer.png b/apps/desktop/assets/AlgorithmWhisperer.png similarity index 100% rename from assets/AlgorithmWhisperer.png rename to apps/desktop/assets/AlgorithmWhisperer.png diff --git a/assets/Aux Cable Apprentice.png b/apps/desktop/assets/Aux Cable Apprentice.png similarity index 100% rename from assets/Aux Cable Apprentice.png rename to apps/desktop/assets/Aux Cable Apprentice.png diff --git a/assets/BackgroundBopper.png b/apps/desktop/assets/BackgroundBopper.png similarity index 100% rename from assets/BackgroundBopper.png rename to apps/desktop/assets/BackgroundBopper.png diff --git a/assets/PlaylistProphet.png b/apps/desktop/assets/PlaylistProphet.png similarity index 100% rename from assets/PlaylistProphet.png rename to apps/desktop/assets/PlaylistProphet.png diff --git a/assets/Skip Button Survivor.png b/apps/desktop/assets/Skip Button Survivor.png similarity index 100% rename from assets/Skip Button Survivor.png rename to apps/desktop/assets/Skip Button Survivor.png diff --git a/assets/auxcableapprentice-notext.png b/apps/desktop/assets/auxcableapprentice-notext.png similarity index 100% rename from assets/auxcableapprentice-notext.png rename to apps/desktop/assets/auxcableapprentice-notext.png diff --git a/assets/backgroundbooper-notext.png b/apps/desktop/assets/backgroundbooper-notext.png similarity index 100% rename from assets/backgroundbooper-notext.png rename to apps/desktop/assets/backgroundbooper-notext.png diff --git a/assets/playlistprophet-notext.png b/apps/desktop/assets/playlistprophet-notext.png similarity index 100% rename from assets/playlistprophet-notext.png rename to apps/desktop/assets/playlistprophet-notext.png diff --git a/assets/skipbuttonsurvivor-notext.png b/apps/desktop/assets/skipbuttonsurvivor-notext.png similarity index 100% rename from assets/skipbuttonsurvivor-notext.png rename to apps/desktop/assets/skipbuttonsurvivor-notext.png diff --git a/assets/taskbar/next.png b/apps/desktop/assets/taskbar/next.png similarity index 100% rename from assets/taskbar/next.png rename to apps/desktop/assets/taskbar/next.png diff --git a/assets/taskbar/pause.png b/apps/desktop/assets/taskbar/pause.png similarity index 100% rename from assets/taskbar/pause.png rename to apps/desktop/assets/taskbar/pause.png diff --git a/assets/taskbar/play.png b/apps/desktop/assets/taskbar/play.png similarity index 100% rename from assets/taskbar/play.png rename to apps/desktop/assets/taskbar/play.png diff --git a/assets/taskbar/prev.png b/apps/desktop/assets/taskbar/prev.png similarity index 100% rename from assets/taskbar/prev.png rename to apps/desktop/assets/taskbar/prev.png diff --git a/eslint.config.mjs b/apps/desktop/eslint.config.mjs similarity index 100% rename from eslint.config.mjs rename to apps/desktop/eslint.config.mjs diff --git a/examples/artist.json b/apps/desktop/examples/artist.json similarity index 100% rename from examples/artist.json rename to apps/desktop/examples/artist.json diff --git a/examples/search.json b/apps/desktop/examples/search.json similarity index 100% rename from examples/search.json rename to apps/desktop/examples/search.json diff --git a/icon.ico b/apps/desktop/icon.ico similarity index 100% rename from icon.ico rename to apps/desktop/icon.ico diff --git a/icon.png b/apps/desktop/icon.png similarity index 100% rename from icon.png rename to apps/desktop/icon.png diff --git a/native/smtc/binding.gyp b/apps/desktop/native/smtc/binding.gyp similarity index 100% rename from native/smtc/binding.gyp rename to apps/desktop/native/smtc/binding.gyp diff --git a/native/smtc/simulate-remote.js b/apps/desktop/native/smtc/simulate-remote.js similarity index 100% rename from native/smtc/simulate-remote.js rename to apps/desktop/native/smtc/simulate-remote.js diff --git a/native/smtc/src/smtc_addon.cpp b/apps/desktop/native/smtc/src/smtc_addon.cpp similarity index 100% rename from native/smtc/src/smtc_addon.cpp rename to apps/desktop/native/smtc/src/smtc_addon.cpp diff --git a/native/smtc/test-smoke.js b/apps/desktop/native/smtc/test-smoke.js similarity index 100% rename from native/smtc/test-smoke.js rename to apps/desktop/native/smtc/test-smoke.js diff --git a/native/smtc/test-window.js b/apps/desktop/native/smtc/test-window.js similarity index 100% rename from native/smtc/test-window.js rename to apps/desktop/native/smtc/test-window.js diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..92f8eff4 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,127 @@ +{ + "name": "true-tunes-2", + "version": "1.0.47", + "description": "Sonos Web Player proxy app", + "repository": "https://github.com/TrueNorthIT/TrueTunes", + "author": { + "name": "True North IT", + "email": "jpitts@truenorthit.co.uk", + "url": "https://truenorthit.co.uk" + }, + "main": "dist/main.js", + "scripts": { + "build:main": "tsc && node -e \"require('dotenv').config({path:['.env','../../.env'],quiet:true});const fs=require('fs');fs.copyFileSync('src/debug-ws.html','dist/debug-ws.html');fs.copyFileSync('src/debug-http.html','dist/debug-http.html');fs.writeFileSync('dist/build-env.json',JSON.stringify({GENIUS_ACCESS_TOKEN:process.env.GENIUS_ACCESS_TOKEN||'',APPINSIGHTS_CONNECTION_STRING:process.env.APPINSIGHTS_CONNECTION_STRING||'',ENTRA_CLIENT_ID:process.env.ENTRA_CLIENT_ID||'',ENTRA_TENANT_ID:process.env.ENTRA_TENANT_ID||''}))\"", + "build:native": "node-gyp rebuild --directory native/smtc", + "build:renderer": "vite build --config renderer/vite.config.ts", + "build": "npm run build:main && npm run build:renderer", + "dev:renderer": "vite --config renderer/vite.config.ts", + "dev": "npm run build:main && concurrently -k \"npm run dev:renderer\" \"electron . --remote-debugging-port=9333\"", + "start": "npm run build && electron .", + "electron:build": "npm run build && electron-builder", + "electron:build:win": "npm run build && npm run build:native && electron-builder --win", + "electron:build:mac": "npm run build && electron-builder --mac", + "electron:publish:win": "npm run build && npm run build:native && electron-builder --win --publish always", + "electron:publish:mac": "npm run build && electron-builder --mac --publish always", + "test": "vitest --config renderer/vitest.config.ts", + "test:ui": "vitest --config renderer/vitest.config.ts --ui", + "test:run": "vitest run --config renderer/vitest.config.ts", + "test:coverage": "vitest run --config renderer/vitest.config.ts --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"**/*.{ts,tsx,json,css}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,json,css}\"", + "typecheck": "tsc --noEmit && tsc --noEmit --project renderer/tsconfig.json", + "test:main": "node --test src/**/*.test.ts" + }, + "dependencies": { + "@azure/msal-node": "^5.2.2", + "applicationinsights": "^3.14.0", + "dompurify": "^3.4.1", + "dotenv": "^17.4.2", + "electron-updater": "^6.8.3", + "marked": "^18.0.2", + "ws": "^8.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tanstack/react-query": "^5.104.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/dompurify": "^3.0.5", + "@types/node": "^25.6.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.3", + "@types/ws": "^8.0.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^5.0.2", + "@vitest/ui": "^5.0.2", + "concurrently": "^9.2.1", + "electron": "41.2.1", + "electron-builder": "^26.8.1", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "jsdom": "^30.1.1", + "liquid-glass-react": "^1.1.1", + "lucide-react": "^1.8.0", + "node-addon-api": "^8.9.2", + "node-gyp": "^13.0.2", + "prettier": "^3.8.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.14.1", + "typescript": "^6.0.2", + "typescript-eslint": "^8.71.0", + "vite": "^8.0.8", + "vitest": "^5.0.2" + }, + "build": { + "appId": "uk.co.truenorthit.truetunes", + "productName": "True Tunes", + "copyright": "Copyright © 2026 True North IT", + "publish": { + "provider": "github", + "owner": "TrueNorthIT", + "repo": "TrueTunes" + }, + "asarUnpack": [ + "native/smtc/build/Release/smtc.node" + ], + "directories": { + "output": "release" + }, + "files": [ + "dist/**", + "renderer/dist/**", + "native/smtc/build/Release/smtc.node", + "package.json" + ], + "extraResources": [ + { + "from": "assets/taskbar", + "to": "assets/taskbar" + } + ], + "win": { + "target": "nsis", + "icon": "icon.ico", + "verifyUpdateCodeSignature": false, + "signtoolOptions": { + "publisherName": "True North IT" + } + }, + "nsis": { + "guid": "com.truenorthit.truetunes", + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "True Tunes" + }, + "mac": { + "target": "dmg", + "icon": "icon.png", + "category": "public.app-category.music" + } + } +} diff --git a/renderer/index.html b/apps/desktop/renderer/index.html similarity index 100% rename from renderer/index.html rename to apps/desktop/renderer/index.html diff --git a/renderer/public/icon.png b/apps/desktop/renderer/public/icon.png similarity index 100% rename from renderer/public/icon.png rename to apps/desktop/renderer/public/icon.png diff --git a/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx similarity index 66% rename from renderer/src/App.tsx rename to apps/desktop/renderer/src/App.tsx index 2c21ed7e..b905cecc 100644 --- a/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -59,12 +59,12 @@ import styles from './styles/App.module.css'; // ── Main app ────────────────────────────────────────────────────────────────── function MainApp() { - const queryClient = useQueryClient(); - const isAuthed = useAuth(); - const groups = useGroups(); - const [activeGroupId, setActiveGroupId] = useState(null); - const [toastMsg, setToastMsg] = useState(null); - const toastTimer = useRef | null>(null); + const queryClient = useQueryClient(); + const isAuthed = useAuth(); + const groups = useGroups(); + const [activeGroupId, setActiveGroupId] = useState(null); + const [toastMsg, setToastMsg] = useState(null); + const toastTimer = useRef | null>(null); const showToast = useCallback((msg: string) => { setToastMsg(msg); if (toastTimer.current) clearTimeout(toastTimer.current); @@ -72,10 +72,21 @@ function MainApp() { }, []); const { playback, applyGroupCache, queueIdRef, queueVersionRef } = usePlayback(activeGroupId); useMediaSession(playback); - const { items: queueItems, setItems: setQueueItems, isLoading: queueLoading, error: queueError, reload: reloadQueueRaw } - = useQueue(isAuthed, activeGroupId, playback.queueId, - (etag) => { queueVersionRef.current = etag; }, - (msg) => showToast(`Queue refresh failed: ${msg}`)); + const { + items: queueItems, + setItems: setQueueItems, + isLoading: queueLoading, + error: queueError, + reload: reloadQueueRaw, + } = useQueue( + isAuthed, + activeGroupId, + playback.queueId, + (etag) => { + queueVersionRef.current = etag; + }, + (msg) => showToast(`Queue refresh failed: ${msg}`) + ); usePrefetchNextLyrics(queueItems, playback.queueItemId); const reloadQueue = useCallback(() => { @@ -92,8 +103,8 @@ function MainApp() { reloadQueue(); }, [playback.queueVersion, reloadQueue]); - const [feedbackOpen, setFeedbackOpen] = useState(false); - const [changelogOpen, setChangelogOpen] = useState(false); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); const [displayName, setDisplayName] = useState(undefined); // undefined = not yet loaded const [entraLoading, setEntraLoading] = useState(false); useEnsureFavourites(displayName); @@ -110,11 +121,17 @@ function MainApp() { shellRef.current?.style.setProperty('--docked-queue-w', `${width}px`); }, []); -useEffect(() => { + useEffect(() => { window.sonos.getDisplayName().then(setDisplayName); - window.sonos.getQueueDockedWidth().then(setQueueDockedWidth).catch(() => {}); + window.sonos + .getQueueDockedWidth() + .then(setQueueDockedWidth) + .catch(() => {}); return window.sonos.onEntraReady(() => { - window.sonos.getDisplayName().then(setDisplayName).catch(() => {}); + window.sonos + .getDisplayName() + .then(setDisplayName) + .catch(() => {}); }); }, []); @@ -196,7 +213,7 @@ useEffect(() => { } as SonosItem, -1, // djFiller: this one really does belong at the very end. - { attribute: false, djFiller: true }, + { attribute: false, djFiller: true } ); }, }); @@ -209,33 +226,50 @@ useEffect(() => { fillerUriRef.current = autoplay.fillerUri; }, [autoplay.fillerUri]); - const handleGroupChange = useCallback((groupId: string) => { - setActiveGroupId(groupId); - getActiveProvider().setGroup(groupId); - applyGroupCache(groupId); - window.sonos.refreshAttribution().catch(() => {}); - window.sonos.trackEvent('group_changed').catch(() => {}); - }, [applyGroupCache]); + const handleGroupChange = useCallback( + (groupId: string) => { + setActiveGroupId(groupId); + getActiveProvider().setGroup(groupId); + applyGroupCache(groupId); + window.sonos.refreshAttribution().catch(() => {}); + window.sonos.trackEvent('group_changed').catch(() => {}); + }, + [applyGroupCache] + ); const publishTrackAttribution = useCallback( - (trackId: string, serviceId: string, accountId: string, fallback: { trackName: string; artist?: string; imageUrl?: string }) => { + ( + trackId: string, + serviceId: string, + accountId: string, + fallback: { trackName: string; artist?: string; imageUrl?: string } + ) => { // Use cached metadata if available; publish immediately with fallback so attribution // is never lost because of a secondary metadata fetch failing. - const cached = queryClient.getQueryData( - trackQueryOptions(trackId, serviceId, accountId).queryKey - ) as { trackName?: string; artist?: string; artistId?: string; albumName?: string; albumId?: string; artUrl?: string } | undefined; - window.sonos.publishQueued({ - eventType: 'track', - uri: trackId, - trackName: cached?.trackName ?? fallback.trackName, - artist: cached?.artist ?? fallback.artist ?? '', - serviceId, - accountId, - artistId: cached?.artistId, - album: cached?.albumName, - albumId: cached?.albumId, - imageUrl: cached?.artUrl ?? fallback.imageUrl, - }).catch(() => {}); + const cached = queryClient.getQueryData(trackQueryOptions(trackId, serviceId, accountId).queryKey) as + | { + trackName?: string; + artist?: string; + artistId?: string; + albumName?: string; + albumId?: string; + artUrl?: string; + } + | undefined; + window.sonos + .publishQueued({ + eventType: 'track', + uri: trackId, + trackName: cached?.trackName ?? fallback.trackName, + artist: cached?.artist ?? fallback.artist ?? '', + serviceId, + accountId, + artistId: cached?.artistId, + album: cached?.albumName, + albumId: cached?.albumId, + imageUrl: cached?.artUrl ?? fallback.imageUrl, + }) + .catch(() => {}); }, [queryClient] ); @@ -285,9 +319,7 @@ useEffect(() => { // it — the filler only exists because nothing else was queued, so it must // never jump ahead of something someone actually chose. if (position === -1 && !opts?.djFiller && fillerUriRef.current) { - const fillerIndex = queueItemsRef.current.findIndex( - (q) => q.track.id === fillerUriRef.current, - ); + const fillerIndex = queueItemsRef.current.findIndex((q) => q.track.id === fillerUriRef.current); if (fillerIndex >= 0) position = fillerIndex; } // DJ picks pass attribute:false. They're the algorithm's choice, not a @@ -395,19 +427,19 @@ useEffect(() => { if (albumId && aSvc && aAcc) { // Publish album-level event immediately with card data as fallback so attribution // is captured even if the album browse fails. Fan-out track events follow async. - window.sonos.publishQueued({ - eventType: 'album', - uri, - trackName: getName(item), - artist: - ((item as Record)['subtitle'] as string) || - (item.artists?.[0]?.name ?? ''), - serviceId: aSvc, - accountId: aAcc, - album: getName(item), - albumId: uri, - imageUrl: getItemArt(item) ?? undefined, - }).catch(() => {}); + window.sonos + .publishQueued({ + eventType: 'album', + uri, + trackName: getName(item), + artist: ((item as Record)['subtitle'] as string) || (item.artists?.[0]?.name ?? ''), + serviceId: aSvc, + accountId: aAcc, + album: getName(item), + albumId: uri, + imageUrl: getItemArt(item) ?? undefined, + }) + .catch(() => {}); queryClient .fetchQuery(albumQueryOptions(albumId, aSvc, aAcc, defaults)) .then((album) => { @@ -422,7 +454,9 @@ useEffect(() => { accountId: aAcc, }); }) - .catch(() => { /* silent — album event already published above */ }); + .catch(() => { + /* silent — album event already published above */ + }); } } else if (attribute && isPlaylistItem) { // Playlists have no albumMap rollup — fan out per-track only. Each @@ -490,116 +524,153 @@ useEffect(() => { return ( - - - {skinny ? ( - - ) : ( -
- window.sonos.resync()} - displayName={displayName} - onSaveName={(name) => { - const stored = name.trim(); - void window.sonos.setDisplayName(stored).then((result) => { - if (!result?.error) setDisplayName(stored || null); - }).catch(() => {}); - }} - onChangelogOpen={() => setChangelogOpen(true)} - /> -
- - - } + + + {skinny ? ( + - + window.sonos.resync()} + displayName={displayName} + onSaveName={(name) => { + const stored = name.trim(); + void window.sonos + .setDisplayName(stored) + .then((result) => { + if (!result?.error) setDisplayName(stored || null); + }) + .catch(() => {}); + }} + onChangelogOpen={() => setChangelogOpen(true)} + /> +
+ + + } + /> + + } + /> + } + /> + } /> + + } + /> + } /> + } /> + } /> + } /> + { + setDisplayName(null); + setEntraLoading(true); + await window.sonos.entraSignOut().catch(() => {}); + await window.sonos.entraReLogin().catch(() => {}); + const name = await window.sonos.getDisplayName().catch(() => null); + setDisplayName(name); + setEntraLoading(false); + }} + onChangeName={async (name) => { + const result = await window.sonos + .renameUser(displayName ?? '', name) + .catch(() => ({ error: 'network' })); + if (!result?.error) setDisplayName(name); + return result?.error ? { error: result.error } : null; + }} + /> + } + /> + } + /> + + g.id === activeGroupId)?.name ?? null} + onRefresh={reloadQueue} + onError={showToast} onAddToQueue={handleAddToQueue} - ytm={ytm} - ytmLoading={ytmLoading} - displayName={displayName} + dockedWidth={queueDockedWidth} + onResizeWidth={handleSetQueueDockedWidth} + onResizeWidthLive={handleResizeWidthLive} + autoplay={autoplay} /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - { setDisplayName(null); setEntraLoading(true); await window.sonos.entraSignOut().catch(() => {}); await window.sonos.entraReLogin().catch(() => {}); const name = await window.sonos.getDisplayName().catch(() => null); setDisplayName(name); setEntraLoading(false); }} onChangeName={async (name) => { const result = await window.sonos.renameUser(displayName ?? '', name).catch(() => ({ error: 'network' })); if (!result?.error) setDisplayName(name); return result?.error ? { error: result.error } : null; }} />} /> - } /> - - g.id === activeGroupId)?.name ?? null} - onRefresh={reloadQueue} - onError={showToast} - onAddToQueue={handleAddToQueue} - dockedWidth={queueDockedWidth} - onResizeWidth={handleSetQueueDockedWidth} - onResizeWidthLive={handleResizeWidthLive} - autoplay={autoplay} - /> -
- -
- )} - {toastMsg &&
{toastMsg}
} - {entraLoading && ( -
- Signing in with your organisation account… -
- )} - {feedbackOpen && setFeedbackOpen(false)} />} - {changelogOpen && setChangelogOpen(false)} />} - +
+ + + )} + {toastMsg &&
{toastMsg}
} + {entraLoading && ( +
+ Signing in with your organisation account… +
+ )} + {feedbackOpen && setFeedbackOpen(false)} />} + {changelogOpen && setChangelogOpen(false)} />} +
); } diff --git a/renderer/src/__tests__/queueAdd.test.tsx b/apps/desktop/renderer/src/__tests__/queueAdd.test.tsx similarity index 69% rename from renderer/src/__tests__/queueAdd.test.tsx rename to apps/desktop/renderer/src/__tests__/queueAdd.test.tsx index 276ed640..1b510285 100644 --- a/renderer/src/__tests__/queueAdd.test.tsx +++ b/apps/desktop/renderer/src/__tests__/queueAdd.test.tsx @@ -8,26 +8,35 @@ import type { SonosItem } from '../types/sonos'; // ── Hoisted refs so vi.mock factories (which are hoisted too) can reference them const { mockQueueVersionRef, mockQueueIdRef } = vi.hoisted(() => ({ mockQueueVersionRef: { current: null as string | null }, - mockQueueIdRef: { current: null as string | null }, + mockQueueIdRef: { current: null as string | null }, })); // ── Hook mocks ──────────────────────────────────────────────────────────────── -vi.mock('../hooks/useAuth', () => ({ useAuth: () => true })); +vi.mock('../hooks/useAuth', () => ({ useAuth: () => true })); vi.mock('../hooks/useGroups', () => ({ useGroups: () => [{ id: 'g1', name: 'Room', coordinatorId: 'c1', playerIds: [] }], })); vi.mock('../hooks/usePlayback', () => ({ usePlayback: () => ({ playback: { - queueId: 'q1', queueVersion: null, isPlaying: false, - currentObjectId: null, queueItemId: null, - progress: 0, duration: 0, trackName: '', artist: '', - artUrl: null, isNoGroups: false, shuffle: false, repeat: 'none', + queueId: 'q1', + queueVersion: null, + isPlaying: false, + currentObjectId: null, + queueItemId: null, + progress: 0, + duration: 0, + trackName: '', + artist: '', + artUrl: null, + isNoGroups: false, + shuffle: false, + repeat: 'none', isVisible: true, }, applyGroupCache: vi.fn(), - queueIdRef: mockQueueIdRef, + queueIdRef: mockQueueIdRef, queueVersionRef: mockQueueVersionRef, }), })); @@ -53,21 +62,21 @@ vi.mock('../components/HomePanel', () => ({ fetchYtmSections: vi.fn().mockResolvedValue({ sections: [] }), })); -vi.mock('../components/TopNav', () => ({ TopNav: () => null })); +vi.mock('../components/TopNav', () => ({ TopNav: () => null })); vi.mock('../components/PlayerBar', () => ({ PlayerBar: () => null })); -vi.mock('../components/Splash', () => ({ Splash: () => null })); +vi.mock('../components/Splash', () => ({ Splash: () => null })); vi.mock('../components/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: { children: unknown }) => children, })); -vi.mock('../components/MiniPlayer', () => ({ MiniPlayerShell: () => null })); -vi.mock('../components/DisplayNameModal', () => ({ DisplayNameModal: () => null })); -vi.mock('../components/FeedbackDialog', () => ({ FeedbackDialog: () => null })); -vi.mock('../components/ChangelogDialog', () => ({ ChangelogDialog: () => null })); -vi.mock('../components/album/AlbumPanel', () => ({ AlbumPanel: () => null })); -vi.mock('../components/artist/ArtistPanel', () => ({ ArtistPanel: () => null })); -vi.mock('../components/ContainerPanel', () => ({ ContainerPanel: () => null })); -vi.mock('../components/LeaderboardPanel', () => ({ LeaderboardPanel: () => null })); -vi.mock('../components/QueuedlePanel', () => ({ QueuedlePanel: () => null })); +vi.mock('../components/MiniPlayer', () => ({ MiniPlayerShell: () => null })); +vi.mock('../components/DisplayNameModal', () => ({ DisplayNameModal: () => null })); +vi.mock('../components/FeedbackDialog', () => ({ FeedbackDialog: () => null })); +vi.mock('../components/ChangelogDialog', () => ({ ChangelogDialog: () => null })); +vi.mock('../components/album/AlbumPanel', () => ({ AlbumPanel: () => null })); +vi.mock('../components/artist/ArtistPanel', () => ({ ArtistPanel: () => null })); +vi.mock('../components/ContainerPanel', () => ({ ContainerPanel: () => null })); +vi.mock('../components/LeaderboardPanel', () => ({ LeaderboardPanel: () => null })); +vi.mock('../components/QueuedlePanel', () => ({ QueuedlePanel: () => null })); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -100,7 +109,7 @@ function renderApp() { - , + ); } @@ -112,8 +121,7 @@ beforeEach(() => { // Default: queue list resolves with an etag; everything else resolves empty. mockFetch.mockImplementation(async (req: { operationId: string }) => { - if (req.operationId === 'getQueueResources') - return { data: { items: [] }, etag: 'etag-v1' }; + if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; return { data: null }; }); @@ -138,40 +146,46 @@ describe('handleAddToQueue — success on first attempt', () => { it('calls addQueueResource exactly once', async () => { mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); - const addCalls = mockFetch.mock.calls.filter(c => c[0].operationId === 'addQueueResource'); + const addCalls = mockFetch.mock.calls.filter((c) => c[0].operationId === 'addQueueResource'); expect(addCalls).toHaveLength(1); }); it('uses the current etag as the If-Match header', async () => { mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); - const [addCall] = mockFetch.mock.calls.filter(c => c[0].operationId === 'addQueueResource'); + const [addCall] = mockFetch.mock.calls.filter((c) => c[0].operationId === 'addQueueResource'); expect(addCall[0].headers?.['If-Match']).toBe('etag-v1'); }); it('updates queueVersionRef with the etag from the response', async () => { mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); expect(mockQueueVersionRef.current).toBe('etag-after'); }); @@ -179,12 +193,14 @@ describe('handleAddToQueue — success on first attempt', () => { it('does not show a toast', async () => { mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); expect(screen.queryByText(/Add failed/)).toBeNull(); }); @@ -192,16 +208,18 @@ describe('handleAddToQueue — success on first attempt', () => { it('does not trigger an extra queue reload for a single track', async () => { mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); - const listCountBefore = mockFetch.mock.calls.filter(c => c[0].operationId === 'getQueueResources').length; + const listCountBefore = mockFetch.mock.calls.filter((c) => c[0].operationId === 'getQueueResources').length; - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); - const listCountAfter = mockFetch.mock.calls.filter(c => c[0].operationId === 'getQueueResources').length; + const listCountAfter = mockFetch.mock.calls.filter((c) => c[0].operationId === 'getQueueResources').length; expect(listCountAfter).toBe(listCountBefore); // no extra list call }); }); @@ -211,7 +229,7 @@ describe('handleAddToQueue — success on first attempt', () => { describe('handleAddToQueue — retry after stale etag failure', () => { function makeRetryMock() { let listCount = 0; - let addCount = 0; + let addCount = 0; mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') { listCount++; @@ -232,7 +250,9 @@ describe('handleAddToQueue — retry after stale etag failure', () => { await mountAndSettle(); const listBefore = getListCount(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); // At least one reload fires to refresh the etag before the retry. // A second reload may also fire afterwards to sync the UI. @@ -242,9 +262,11 @@ describe('handleAddToQueue — retry after stale etag failure', () => { it('retries addQueueResource with the fresh etag', async () => { makeRetryMock(); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); - const addCalls = mockFetch.mock.calls.filter(c => c[0].operationId === 'addQueueResource'); + const addCalls = mockFetch.mock.calls.filter((c) => c[0].operationId === 'addQueueResource'); expect(addCalls).toHaveLength(2); expect(addCalls[1][0].headers?.['If-Match']).toBe('etag-fresh'); }); @@ -252,7 +274,9 @@ describe('handleAddToQueue — retry after stale etag failure', () => { it('does not show a toast when the retry succeeds', async () => { makeRetryMock(); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); expect(screen.queryByText(/Add failed/)).toBeNull(); }); @@ -260,7 +284,9 @@ describe('handleAddToQueue — retry after stale etag failure', () => { it('queueVersionRef holds the latest etag after retry completes', async () => { makeRetryMock(); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); // The post-retry reloadQueue fires and overwrites with the list response etag — // that is the authoritative version after the queue settles. @@ -278,8 +304,7 @@ describe('handleAddToQueue — double failure shows toast', () => { listCount++; return { data: { items: [] }, etag: listCount === 1 ? 'etag-v1' : 'etag-fresh' }; } - if (req.operationId === 'addQueueResource') - return { data: null, error: 'Precondition Failed' }; + if (req.operationId === 'addQueueResource') return { data: null, error: 'Precondition Failed' }; return { data: null }; }); return { getListCount: () => listCount }; @@ -288,7 +313,9 @@ describe('handleAddToQueue — double failure shows toast', () => { it('shows an error toast', async () => { makeDoubleFailMock(); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); expect(screen.getByText(/Add failed/)).toBeInTheDocument(); }); @@ -296,9 +323,11 @@ describe('handleAddToQueue — double failure shows toast', () => { it('calls addQueueResource twice (original + retry)', async () => { makeDoubleFailMock(); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); - const addCalls = mockFetch.mock.calls.filter(c => c[0].operationId === 'addQueueResource'); + const addCalls = mockFetch.mock.calls.filter((c) => c[0].operationId === 'addQueueResource'); expect(addCalls).toHaveLength(2); }); @@ -307,7 +336,9 @@ describe('handleAddToQueue — double failure shows toast', () => { await mountAndSettle(); const listBefore = getListCount(); - await act(async () => { await capturedAddToQueue!(makeTrackItem()); }); + await act(async () => { + await capturedAddToQueue!(makeTrackItem()); + }); expect(getListCount()).toBe(listBefore + 1); }); @@ -319,15 +350,20 @@ describe('handleAddToQueue — non-track item', () => { it('reloads queue after a successful album add', async () => { let listCount = 0; mockFetch.mockImplementation(async (req: { operationId: string }) => { - if (req.operationId === 'getQueueResources') { listCount++; return { data: { items: [] }, etag: 'etag-v1' }; } - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'getQueueResources') { + listCount++; + return { data: { items: [] }, etag: 'etag-v1' }; + } + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; return { data: null }; }); await mountAndSettle(); const listBefore = listCount; - await act(async () => { await capturedAddToQueue!(makeAlbumItem()); }); + await act(async () => { + await capturedAddToQueue!(makeAlbumItem()); + }); // reloadQueue fires (and a delayed second reload after 1500 ms — not waited here) expect(listCount).toBeGreaterThan(listBefore); @@ -335,34 +371,55 @@ describe('handleAddToQueue — non-track item', () => { it('publishes 1 album event + N per-track events when an album is added', async () => { const albumTracks = [ - { title: 'Track A', ordinal: 1, duration: 180, images: { tile1x1: 'art' }, resource: { id: { objectId: 'trk-a', serviceId: 'gm', accountId: 'acc1' } } }, - { title: 'Track B', ordinal: 2, duration: 200, images: { tile1x1: 'art' }, resource: { id: { objectId: 'trk-b', serviceId: 'gm', accountId: 'acc1' } } }, - { title: 'Track C', ordinal: 3, duration: 220, images: { tile1x1: 'art' }, resource: { id: { objectId: 'trk-c', serviceId: 'gm', accountId: 'acc1' } } }, + { + title: 'Track A', + ordinal: 1, + duration: 180, + images: { tile1x1: 'art' }, + resource: { id: { objectId: 'trk-a', serviceId: 'gm', accountId: 'acc1' } }, + }, + { + title: 'Track B', + ordinal: 2, + duration: 200, + images: { tile1x1: 'art' }, + resource: { id: { objectId: 'trk-b', serviceId: 'gm', accountId: 'acc1' } }, + }, + { + title: 'Track C', + ordinal: 3, + duration: 220, + images: { tile1x1: 'art' }, + resource: { id: { objectId: 'trk-c', serviceId: 'gm', accountId: 'acc1' } }, + }, ]; mockFetch.mockImplementation(async (req: { operationId: string }) => { if (req.operationId === 'getQueueResources') return { data: { items: [] }, etag: 'etag-v1' }; - if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; - if (req.operationId === 'browseAlbum') return { data: { title: 'Test Album', tracks: { items: albumTracks, total: albumTracks.length } } }; + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'etag-after' }; + if (req.operationId === 'browseAlbum') + return { data: { title: 'Test Album', tracks: { items: albumTracks, total: albumTracks.length } } }; if (req.operationId === 'getTrackNowPlaying') return { data: {} }; // empty body — parseResponse tolerates it; publish uses fallback trackName return { data: null }; }); const publishMock = vi.mocked(window.sonos.publishQueued).mockResolvedValue(undefined); await mountAndSettle(); - await act(async () => { await capturedAddToQueue!(makeAlbumItem()); }); + await act(async () => { + await capturedAddToQueue!(makeAlbumItem()); + }); // Wait for the fan-out: 1 album event + 3 track events. await waitFor(() => expect(publishMock).toHaveBeenCalledTimes(4)); const calls = publishMock.mock.calls.map(([arg]) => arg); - const albumEvents = calls.filter(c => c.eventType === 'album'); - const trackEvents = calls.filter(c => c.eventType === 'track'); + const albumEvents = calls.filter((c) => c.eventType === 'album'); + const trackEvents = calls.filter((c) => c.eventType === 'track'); expect(albumEvents).toHaveLength(1); expect(trackEvents).toHaveLength(3); // Album event uses the album URI; track events use track URIs (never the album URI). expect(albumEvents[0].uri).toBe('alb1'); - const trackUris = trackEvents.map(e => e.uri).sort(); + const trackUris = trackEvents.map((e) => e.uri).sort(); expect(trackUris).toEqual(['trk-a', 'trk-b', 'trk-c']); }); }); diff --git a/renderer/src/components/CardRow.tsx b/apps/desktop/renderer/src/components/CardRow.tsx similarity index 74% rename from renderer/src/components/CardRow.tsx rename to apps/desktop/renderer/src/components/CardRow.tsx index c6ab13b7..bdf55860 100644 --- a/renderer/src/components/CardRow.tsx +++ b/apps/desktop/renderer/src/components/CardRow.tsx @@ -1,4 +1,13 @@ -import { getName, browseSub, getItemArt, isAlbum, isArtist, isPlaylist, isContainer, isProgram } from '../lib/itemHelpers'; +import { + getName, + browseSub, + getItemArt, + isAlbum, + isArtist, + isPlaylist, + isContainer, + isProgram, +} from '../lib/itemHelpers'; import { useArtistImage } from '../hooks/useArtistBrowse'; import { MediaCard } from './common/MediaCard'; import type { SonosItem, SonosItemId } from '../types/sonos'; @@ -7,21 +16,13 @@ import styles from '../styles/CardRow.module.css'; // Artist cards used the item's imageUrl, which for leaderboard-derived artists // is undefined (we no longer seed the artist map with track album-art). Fetch // the real artist image from the Sonos artist endpoint instead. -function ArtistMediaCard({ - item, - onOpen, -}: { - item: SonosItem; - onOpen: (item: SonosItem) => void; -}) { - const rid = (item.resource?.id ?? (typeof item.id === 'object' ? item.id : undefined)) as - | SonosItemId - | undefined; +function ArtistMediaCard({ item, onOpen }: { item: SonosItem; onOpen: (item: SonosItem) => void }) { + const rid = (item.resource?.id ?? (typeof item.id === 'object' ? item.id : undefined)) as SonosItemId | undefined; const { data: artistImg } = useArtistImage( rid?.objectId, rid?.serviceId, rid?.accountId, - item.resource?.defaults as string | undefined, + item.resource?.defaults as string | undefined ); return ( void; cardSize?: string; }) { - const sizeStyle = cardSize ? { '--card-size': cardSize } as React.CSSProperties : undefined; + const sizeStyle = cardSize ? ({ '--card-size': cardSize } as React.CSSProperties) : undefined; if (isLoading) { return (
@@ -71,9 +72,11 @@ export function CardRow({ artUrl={getItemArt(item)} circular={false} onAdd={isContainer(item) ? undefined : () => onAdd(item)} - onOpen={(isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item)) ? () => onOpen(item) : undefined} + onOpen={ + isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item) ? () => onOpen(item) : undefined + } /> - ), + ) )}
); diff --git a/renderer/src/components/ChangelogDialog.tsx b/apps/desktop/renderer/src/components/ChangelogDialog.tsx similarity index 71% rename from renderer/src/components/ChangelogDialog.tsx rename to apps/desktop/renderer/src/components/ChangelogDialog.tsx index e1ce1619..407faf2e 100644 --- a/renderer/src/components/ChangelogDialog.tsx +++ b/apps/desktop/renderer/src/components/ChangelogDialog.tsx @@ -34,41 +34,48 @@ interface Props { } export function ChangelogDialog({ onClose }: Props) { - const [releases, setReleases] = useState([]); + const [releases, setReleases] = useState([]); const [selectedIdx, setSelectedIdx] = useState(0); - const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [appVersion, setAppVersion] = useState(null); const [versionLoaded, setVersionLoaded] = useState(false); useEffect(() => { - window.sonos.getVersion() - .then(v => { setAppVersion(v); setVersionLoaded(true); }) + window.sonos + .getVersion() + .then((v) => { + setAppVersion(v); + setVersionLoaded(true); + }) .catch(() => setVersionLoaded(true)); }, []); useEffect(() => { fetchReleases() - .then(data => { setReleases(data); setStatus('ready'); }) + .then((data) => { + setReleases(data); + setStatus('ready'); + }) .catch(() => setStatus('error')); }, []); useEffect(() => { if (!versionLoaded || status !== 'ready' || !appVersion || releases.length === 0) return; - const idx = releases.findIndex(r => - r.tag_name === `v${appVersion}` || r.tag_name === appVersion - ); + const idx = releases.findIndex((r) => r.tag_name === `v${appVersion}` || r.tag_name === appVersion); if (idx >= 0) setSelectedIdx(idx); }, [versionLoaded, status, appVersion, releases]); useEffect(() => { - function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); const selected = releases[selectedIdx]; - const isCurrent = selected && appVersion && - (selected.tag_name === `v${appVersion}` || selected.tag_name === appVersion); + const isCurrent = + selected && appVersion && (selected.tag_name === `v${appVersion}` || selected.tag_name === appVersion); return (
@@ -76,27 +83,22 @@ export function ChangelogDialog({ onClose }: Props) {
What's New {appVersion && v{appVersion}} - +
- {status === 'loading' && ( -
Loading releases…
- )} + {status === 'loading' &&
Loading releases…
} - {status === 'error' && ( -
Could not load release notes.
- )} + {status === 'error' &&
Could not load release notes.
} - {status === 'ready' && releases.length === 0 && ( -
No releases found.
- )} + {status === 'ready' && releases.length === 0 &&
No releases found.
} {status === 'ready' && releases.length > 0 && (
{releases.map((r, i) => { - const isCur = appVersion && - (r.tag_name === `v${appVersion}` || r.tag_name === appVersion); + const isCur = appVersion && (r.tag_name === `v${appVersion}` || r.tag_name === appVersion); return (
- {selected.body?.trim() - ?
- : No release notes. - } + {selected.body?.trim() ? ( +
+ ) : ( + No release notes. + )}
)} diff --git a/renderer/src/components/ContainerPanel.tsx b/apps/desktop/renderer/src/components/ContainerPanel.tsx similarity index 85% rename from renderer/src/components/ContainerPanel.tsx rename to apps/desktop/renderer/src/components/ContainerPanel.tsx index 1597bc21..03c564f7 100644 --- a/renderer/src/components/ContainerPanel.tsx +++ b/apps/desktop/renderer/src/components/ContainerPanel.tsx @@ -3,14 +3,7 @@ import { useImage } from '../hooks/useImage'; import { useContainerBrowse } from '../hooks/useContainerBrowse'; import { useOpenItem } from '../hooks/useOpenItem'; import { MediaCard } from './common/MediaCard'; -import { - getName, - browseSub, - getItemArt, - isProgram, - isTrack, - resolveAlbumParams, -} from '../lib/itemHelpers'; +import { getName, browseSub, getItemArt, isProgram, isTrack, resolveAlbumParams } from '../lib/itemHelpers'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ContainerPanel.module.css'; @@ -23,20 +16,25 @@ export function ContainerPanel({ onAddToQueue }: Props) { const item = (state as { item?: SonosItem } | null)?.item; const openItem = useOpenItem(); - const { albumId: containerId, serviceId, accountId, defaults } = item + const { + albumId: containerId, + serviceId, + accountId, + defaults, + } = item ? resolveAlbumParams(item) : { albumId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined }; const { data, isLoading, error } = useContainerBrowse(containerId, serviceId, accountId, defaults); - const artUrl = item ? getItemArt(item) : null; + const artUrl = item ? getItemArt(item) : null; const cachedArt = useImage(artUrl); - const title = (item?.name ?? item?.title ?? '') as string; - const sub = (item as Record | undefined)?.['subtitle'] as string | undefined; + const title = (item?.name ?? item?.title ?? '') as string; + const sub = (item as Record | undefined)?.['subtitle'] as string | undefined; function openHandler(child: SonosItem) { if (isProgram(child)) return onAddToQueue(child); - if (isTrack(child)) return onAddToQueue(child); + if (isTrack(child)) return onAddToQueue(child); openItem(child); } diff --git a/renderer/src/components/DiscoverPanel.tsx b/apps/desktop/renderer/src/components/DiscoverPanel.tsx similarity index 77% rename from renderer/src/components/DiscoverPanel.tsx rename to apps/desktop/renderer/src/components/DiscoverPanel.tsx index 2958dc07..8b1d3ccf 100644 --- a/renderer/src/components/DiscoverPanel.tsx +++ b/apps/desktop/renderer/src/components/DiscoverPanel.tsx @@ -44,11 +44,7 @@ function DjRow({ track, onAdd }: { track: DjRowTrack; onAdd: () => void }) { const art = useImage(track.imageUrl ?? null); return (
- {art ? ( - - ) : ( -
- )} + {art ? :
}
{track.trackName}
{track.artist}
@@ -83,16 +79,13 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { users: selected.length ? selected : roomKey ? roomKey.split(',') : [], limit: 25, }), - [selected, roomKey], + [selected, roomKey] ); const { data, isLoading, isFetching, refetch } = useDjSet(opts); // Only browse the music service once the user actually asks for new material — // it costs one round trip per act. - const { discoveries, isLoading: loadingNew } = useDjDiscoveries( - data?.artists, - mode === 'discover', - ); + const { discoveries, isLoading: loadingNew } = useDjDiscoveries(data?.artists, mode === 'discover'); const tracks = mode === 'discover' @@ -110,9 +103,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { const busy = isLoading || (mode === 'discover' && loadingNew); function toggle(userId: string) { - setSelected((prev) => - prev.includes(userId) ? prev.filter((u) => u !== userId) : [...prev, userId], - ); + setSelected((prev) => (prev.includes(userId) ? prev.filter((u) => u !== userId) : [...prev, userId])); } // Presence tells us who's actually in the building; anyone it can't vouch for @@ -152,8 +143,8 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { Discover

- Music for whoever's in the room, from what the office actually plays. Picks are - scored so the least keen listener still gets a say. + Music for whoever's in the room, from what the office actually plays. Picks are scored so the least keen + listener still gets a say.

@@ -172,12 +163,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { Favourites
- @@ -198,36 +184,39 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { {/* Split by where people actually are. A single flat list gave no clue which names were driving the picks, so the set looked arbitrary. */} - {chipGroups.map((group) => ( - group.users.length > 0 && ( -
- {group.label} -
- {group.users.map((u) => { - const active = selected.includes(u.userId); - const inRoom = selected.length === 0 && group.key === 'here'; - return ( - - ); - })} + {chipGroups.map( + (group) => + group.users.length > 0 && ( +
+ {group.label} +
+ {group.users.map((u) => { + const active = selected.includes(u.userId); + const inRoom = selected.length === 0 && group.key === 'here'; + return ( + + ); + })} +
-
- ) - ))} + ) + )} {selected.length === 0 && ( {presence?.basis === 'sensed' @@ -245,13 +234,9 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) {
{busy && ( -
- {mode === 'discover' ? 'Digging for something new…' : 'Reading the room…'} -
- )} - {!busy && data?.error && ( -
Couldn't build a set: {data.error}
+
{mode === 'discover' ? 'Digging for something new…' : 'Reading the room…'}
)} + {!busy && data?.error &&
Couldn't build a set: {data.error}
} {!busy && !data?.error && tracks.length === 0 && (
{mode === 'discover' @@ -260,11 +245,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) {
)} {tracks.map((track) => ( - onAddToQueue(track.item, -1, { attribute: false })} - /> + onAddToQueue(track.item, -1, { attribute: false })} /> ))}
diff --git a/renderer/src/components/DisplayNameModal.tsx b/apps/desktop/renderer/src/components/DisplayNameModal.tsx similarity index 93% rename from renderer/src/components/DisplayNameModal.tsx rename to apps/desktop/renderer/src/components/DisplayNameModal.tsx index 63af8766..4a5387a7 100644 --- a/renderer/src/components/DisplayNameModal.tsx +++ b/apps/desktop/renderer/src/components/DisplayNameModal.tsx @@ -38,7 +38,10 @@ export function DisplayNameModal({ onSave, defaultName }: Props) { type="text" placeholder="Your name" value={value} - onChange={(e) => { setValue(e.target.value); setError(null); }} + onChange={(e) => { + setValue(e.target.value); + setError(null); + }} autoFocus maxLength={32} spellCheck={false} diff --git a/renderer/src/components/ErrorBoundary.tsx b/apps/desktop/renderer/src/components/ErrorBoundary.tsx similarity index 100% rename from renderer/src/components/ErrorBoundary.tsx rename to apps/desktop/renderer/src/components/ErrorBoundary.tsx diff --git a/renderer/src/components/FeedbackDialog.tsx b/apps/desktop/renderer/src/components/FeedbackDialog.tsx similarity index 79% rename from renderer/src/components/FeedbackDialog.tsx rename to apps/desktop/renderer/src/components/FeedbackDialog.tsx index 7dd36dfb..ff6973cc 100644 --- a/renderer/src/components/FeedbackDialog.tsx +++ b/apps/desktop/renderer/src/components/FeedbackDialog.tsx @@ -8,7 +8,7 @@ interface Props { type IssueType = 'bug' | 'feature'; const REPO = 'TrueNorthIT/TrueTunes'; -const PAT = import.meta.env.VITE_GITHUB_PAT as string | undefined; +const PAT = import.meta.env.VITE_GITHUB_PAT as string | undefined; async function createGitHubIssue(title: string, body: string, type: IssueType) { if (!PAT) throw new Error('VITE_GITHUB_PAT is not set'); @@ -23,23 +23,26 @@ async function createGitHubIssue(title: string, body: string, type: IssueType) { body: JSON.stringify({ title, body, labels: [label] }), }); if (!res.ok) { - const err = await res.json().catch(() => ({})) as { message?: string }; + const err = (await res.json().catch(() => ({}))) as { message?: string }; throw new Error(err.message ?? `GitHub API error ${res.status}`); } return (await res.json()) as { html_url: string }; } export function FeedbackDialog({ onClose }: Props) { - const [type, setType] = useState('bug'); - const [title, setTitle] = useState(''); - const [description, setDesc] = useState(''); - const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); - const [issueUrl, setIssueUrl] = useState(''); - const [errorMsg, setErrorMsg] = useState(''); + const [type, setType] = useState('bug'); + const [title, setTitle] = useState(''); + const [description, setDesc] = useState(''); + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [issueUrl, setIssueUrl] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); const [displayName, setDisplayName] = useState(null); useEffect(() => { - window.sonos.getDisplayName().then(setDisplayName).catch(() => null); + window.sonos + .getDisplayName() + .then(setDisplayName) + .catch(() => null); }, []); const canSubmit = title.trim().length > 0; @@ -67,19 +70,21 @@ export function FeedbackDialog({ onClose }: Props) { return (
- + {status === 'success' ? (
✓

Submitted!

-

- Your {type === 'bug' ? 'bug report' : 'feature request'} has been created. -

+

Your {type === 'bug' ? 'bug report' : 'feature request'} has been created.

- +
) : ( <> @@ -127,14 +132,8 @@ export function FeedbackDialog({ onClose }: Props) { rows={5} disabled={status === 'loading'} /> - {status === 'error' && ( -

{errorMsg}

- )} - diff --git a/renderer/src/components/HomePanel.tsx b/apps/desktop/renderer/src/components/HomePanel.tsx similarity index 81% rename from renderer/src/components/HomePanel.tsx rename to apps/desktop/renderer/src/components/HomePanel.tsx index ec4d9654..9e4b300d 100644 --- a/renderer/src/components/HomePanel.tsx +++ b/apps/desktop/renderer/src/components/HomePanel.tsx @@ -60,9 +60,14 @@ function AlbumListItem({ item, onOpen, onAdd }: { item: SonosItem; onOpen: () =>
+ > + + +
); } @@ -72,10 +77,7 @@ function ArtistGridCell({ item, onOpen }: { item: SonosItem; onOpen: () => void return (
- {art - ? - :
♪
- } + {art ? :
♪
}
{getName(item)}
@@ -158,7 +160,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName const [selectedUser, setSelectedUser] = useState(undefined); const [pickerOpen, setPickerOpen] = useState(false); const pickerRef = useRef(null); - const { artistItems, albumItems, availableUsers, currentUserId, isLoading: recentLoading } = useRecentlyPlayed(selectedUser); + const { + artistItems, + albumItems, + availableUsers, + currentUserId, + isLoading: recentLoading, + } = useRecentlyPlayed(selectedUser); // Default to current user once we know who they are useEffect(() => { @@ -222,7 +230,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName const hasRecent = !!selectedUser || recentLoading || artistItems.length > 0 || albumItems.length > 0; const recentEmpty = !!selectedUser && !recentLoading && artistItems.length === 0 && albumItems.length === 0; - const users = availableUsers.length > 0 ? availableUsers : (currentUserId ? [currentUserId] : []); + const users = availableUsers.length > 0 ? availableUsers : currentUserId ? [currentUserId] : []; const isPickerLocked = users.length > 1 && !hasCompletedToday; return ( @@ -240,7 +248,9 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName {(colleaguesLoading || colleagues.length > 0) && (
-

People

+

+ People +

{colleaguesLoading ? Array.from({ length: 5 }).map((_, i) => ( @@ -252,8 +262,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName user={u} onClick={() => navigate(`/profile/${encodeURIComponent(u.userId)}`)} /> - )) - } + ))}
)} @@ -262,7 +271,9 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

Playlists

- +
{[...ownedPlaylists, ...joinedPlaylists] @@ -271,13 +282,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName return b.updatedAt - a.updatedAt; }) .map((pl) => ( - navigate(`/playlist/${pl.id}`)} - /> - ))} + navigate(`/playlist/${pl.id}`)} + /> + ))}
)} @@ -287,14 +298,14 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

Recently Played — Last 7 Days - {selectedUser && ( - — - )} + {selectedUser && — } {selectedUser && (

- {recentEmpty && ( -
Nothing queued in the last 7 days
- )} + {recentEmpty &&
Nothing queued in the last 7 days
} {recentLoading && (
@@ -371,39 +386,42 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

Albums

{albumItems.map((item, i) => ( - openItem(item)} onAdd={() => onAddToQueue(item)} /> + openItem(item)} + onAdd={() => onAddToQueue(item)} + /> ))}
)} - {artistItems.length > 0 && (() => { - const noAlbums = albumItems.length === 0; - const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); - const numRows = Math.ceil(artistItems.length / numCols); - const gridHeight = noAlbums - ? numRows === 1 ? 200 : numRows * 80 - : albumItems.length * 62 - 2; - return ( -
-

Artists

-
- {artistItems.map((item, i) => ( - openItem(item)} /> - ))} + {artistItems.length > 0 && + (() => { + const noAlbums = albumItems.length === 0; + const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); + const numRows = Math.ceil(artistItems.length / numCols); + const gridHeight = noAlbums ? (numRows === 1 ? 200 : numRows * 80) : albumItems.length * 62 - 2; + return ( +
+

Artists

+
+ {artistItems.map((item, i) => ( + openItem(item)} /> + ))} +
-
- ); - })()} + ); + })()}
)} diff --git a/renderer/src/components/LeaderboardPanel.tsx b/apps/desktop/renderer/src/components/LeaderboardPanel.tsx similarity index 94% rename from renderer/src/components/LeaderboardPanel.tsx rename to apps/desktop/renderer/src/components/LeaderboardPanel.tsx index 18beb846..3d2c489e 100644 --- a/renderer/src/components/LeaderboardPanel.tsx +++ b/apps/desktop/renderer/src/components/LeaderboardPanel.tsx @@ -211,7 +211,12 @@ export function LeaderboardPanel() { - +
- +
@@ -314,7 +327,9 @@ export function LeaderboardPanel() { }, }) } - onResolveArtist={(name) => resolveAndOpen(name, 'artist', { serviceId: t.serviceId, accountId: t.accountId })} + onResolveArtist={(name) => + resolveAndOpen(name, 'artist', { serviceId: t.serviceId, accountId: t.accountId }) + } /> ) : null} {t.artist && t.album ? ' · ' : null} @@ -446,7 +461,9 @@ export function LeaderboardPanel() { }, }) } - onResolveArtist={(name) => resolveAndOpen(name, 'artist', { serviceId: a.serviceId, accountId: a.accountId })} + onResolveArtist={(name) => + resolveAndOpen(name, 'artist', { serviceId: a.serviceId, accountId: a.accountId }) + } /> ) : null} @@ -503,9 +520,7 @@ export function LeaderboardPanel() { ); })}
-
- Fewer than 3 played days = Provisional (no tier) -
+
Fewer than 3 played days = Provisional (no tier)
)} diff --git a/renderer/src/components/LyricsPanel.module.css b/apps/desktop/renderer/src/components/LyricsPanel.module.css similarity index 89% rename from renderer/src/components/LyricsPanel.module.css rename to apps/desktop/renderer/src/components/LyricsPanel.module.css index d32b2dee..2c0a198a 100644 --- a/renderer/src/components/LyricsPanel.module.css +++ b/apps/desktop/renderer/src/components/LyricsPanel.module.css @@ -13,8 +13,12 @@ } @keyframes panelIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } /* ── Background layers ── */ @@ -173,7 +177,9 @@ color: #fff; line-height: 1.4; cursor: default; - transition: opacity 0.55s ease, font-size 0.45s ease; + transition: + opacity 0.55s ease, + font-size 0.45s ease; } .lineActive { @@ -226,17 +232,36 @@ animation: dot 1.2s ease-in-out infinite; } -.dots span:nth-child(2) { animation-delay: 0.2s; } -.dots span:nth-child(3) { animation-delay: 0.4s; } +.dots span:nth-child(2) { + animation-delay: 0.2s; +} +.dots span:nth-child(3) { + animation-delay: 0.4s; +} @keyframes breathe { - 0%, 100% { opacity: 0.45; transform: scale(0.96); } - 50% { opacity: 1; transform: scale(1.04); } + 0%, + 100% { + opacity: 0.45; + transform: scale(0.96); + } + 50% { + opacity: 1; + transform: scale(1.04); + } } @keyframes dot { - 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-4px); } + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-4px); + } } @media (max-width: 800px) { diff --git a/renderer/src/components/LyricsPanel.tsx b/apps/desktop/renderer/src/components/LyricsPanel.tsx similarity index 75% rename from renderer/src/components/LyricsPanel.tsx rename to apps/desktop/renderer/src/components/LyricsPanel.tsx index 4b8c6801..8b4bb287 100644 --- a/renderer/src/components/LyricsPanel.tsx +++ b/apps/desktop/renderer/src/components/LyricsPanel.tsx @@ -16,13 +16,35 @@ function renderDom(node: GeniusDomNode, key: number | string): React.ReactNode { if (typeof node === 'string') return node; const children = node.children?.map((c, i) => renderDom(c, i)) ?? null; switch (node.tag) { - case 'root': return <>{children}; - case 'p': return

{children}

; - case 'em': return {children}; - case 'strong': return {children}; - case 'a': return { const href = node.attributes?.href; if (href) window.sonos.openExternal(href); }}>{children}; - case 'br': return
; - default: return {children}; + case 'root': + return <>{children}; + case 'p': + return ( +

+ {children} +

+ ); + case 'em': + return {children}; + case 'strong': + return {children}; + case 'a': + return ( + { + const href = node.attributes?.href; + if (href) window.sonos.openExternal(href); + }} + > + {children} + + ); + case 'br': + return
; + default: + return {children}; } } @@ -41,8 +63,12 @@ export function LyricsPanel({ playback }: Props) { const elapsedMs = Math.round((progressPct / 100) * durationMs) + 250; - const { lines, isLoading: lyricsLoading, isInstrumental, notFound } = - useLyrics(displayTrack, displayArtist, albumName, durationMs); + const { + lines, + isLoading: lyricsLoading, + isInstrumental, + notFound, + } = useLyrics(displayTrack, displayArtist, albumName, durationMs); const { description } = useGeniusDescription(displayTrack, displayArtist); @@ -65,7 +91,9 @@ export function LyricsPanel({ playback }: Props) { }, [activeLine]); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); @@ -85,27 +113,29 @@ export function LyricsPanel({ playback }: Props) { {cachedArt ? ( ) : ( -
+
+ +
)}
{displayTrack || '—'}
{displayArtist || '—'}
- {description && ( -
- {renderDom(description, 0)} -
- )} + {description &&
{renderDom(description, 0)}
}
{/* Right — scrolling lyrics */}
{lyricsLoading && (
-
+
+ +
Loading
- + + +
)} @@ -117,7 +147,9 @@ export function LyricsPanel({ playback }: Props) { return (
{ lineRefs.current[i] = el; }} + ref={(el) => { + lineRefs.current[i] = el; + }} className={`${styles.line}${i === activeLine ? ' ' + styles.lineActive : ''}`} style={activeLine >= 0 ? { opacity: Math.max(0.18, 1 - dist * 0.19) } : undefined} > diff --git a/renderer/src/components/MiniPlayer.tsx b/apps/desktop/renderer/src/components/MiniPlayer.tsx similarity index 84% rename from renderer/src/components/MiniPlayer.tsx rename to apps/desktop/renderer/src/components/MiniPlayer.tsx index 82073017..616e5e45 100644 --- a/renderer/src/components/MiniPlayer.tsx +++ b/apps/desktop/renderer/src/components/MiniPlayer.tsx @@ -19,10 +19,13 @@ export function MiniPlayerShell() { // same group the user has selected in the main window, not just groups[0]. useEffect(() => { if (groups.length === 0 || activeGroupId) return; - getActiveProvider().getActiveGroup().then((savedCoordinatorId) => { - const match = savedCoordinatorId ? groups.find((g) => g.coordinatorId === savedCoordinatorId) : null; - setActiveGroupId(match ? match.id : groups[0].id); - }).catch(() => setActiveGroupId(groups[0].id)); + getActiveProvider() + .getActiveGroup() + .then((savedCoordinatorId) => { + const match = savedCoordinatorId ? groups.find((g) => g.coordinatorId === savedCoordinatorId) : null; + setActiveGroupId(match ? match.id : groups[0].id); + }) + .catch(() => setActiveGroupId(groups[0].id)); }, [groups, activeGroupId]); const { playback } = usePlayback(activeGroupId); @@ -39,7 +42,17 @@ function MiniPlayer({ playback: ReturnType['playback']; isAuthed: boolean; }) { - const { displayTrack, displayArtist, cachedArt, progressPct, durationMs, isPlaying, dominantColor, elapsedLabel, durationLabel } = useNowPlaying(playback); + const { + displayTrack, + displayArtist, + cachedArt, + progressPct, + durationMs, + isPlaying, + dominantColor, + elapsedLabel, + durationLabel, + } = useNowPlaying(playback); const refresh = () => getActiveProvider().refreshPlayback(); @@ -47,7 +60,9 @@ function MiniPlayer({ if (!durationMs) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - getActiveProvider().seek(Math.floor(pct * durationMs)).then(refresh); + getActiveProvider() + .seek(Math.floor(pct * durationMs)) + .then(refresh); }; const shellStyle: React.CSSProperties | undefined = dominantColor @@ -69,12 +84,7 @@ function MiniPlayer({
{displayTrack || '—'}
{displayArtist || ''}
-
+
@@ -104,7 +114,9 @@ function MiniPlayer({ - ) : } + ) : ( + + )} @@ -212,11 +202,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) const lyricsActive = pathname === '/lyrics'; const openItem = useOpenItem(); const { - displayTrack, displayArtist, cachedArt, dominantColor, - elapsedLabel, durationLabel, progressPct, durationMs, - isPlaying, isVisible, shuffle, repeat, volume, isExplicit, - albumItem, artistItem, prefetchAlbum, - currentObjectId, currentServiceId, currentAccountId, artUrlRaw, + displayTrack, + displayArtist, + cachedArt, + dominantColor, + elapsedLabel, + durationLabel, + progressPct, + durationMs, + isPlaying, + isVisible, + shuffle, + repeat, + volume, + isExplicit, + albumItem, + artistItem, + prefetchAlbum, + currentObjectId, + currentServiceId, + currentAccountId, + artUrlRaw, } = useNowPlaying(playback); const { isFavourited, toggle: toggleFavourite } = useFavourite(displayName, { @@ -234,21 +240,23 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) if (!durationMs) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - getActiveProvider().seek(Math.floor(pct * durationMs)).then(refresh); + getActiveProvider() + .seek(Math.floor(pct * durationMs)) + .then(refresh); }; const refresh = () => getActiveProvider().refreshPlayback(); - const toggleShuffle = () => - getActiveProvider().setPlayModes({ shuffle: !rawShuffle }).then(refresh).then(onShuffle); + const toggleShuffle = () => getActiveProvider().setPlayModes({ shuffle: !rawShuffle }).then(refresh).then(onShuffle); const toggleRepeat = () => { - const next = rawRepeat === "none" ? "all" : rawRepeat === "all" ? "one" : "none"; - getActiveProvider().setPlayModes({ repeat: next as 'none' | 'one' | 'all' }).then(refresh); + const next = rawRepeat === 'none' ? 'all' : rawRepeat === 'all' ? 'one' : 'none'; + getActiveProvider() + .setPlayModes({ repeat: next as 'none' | 'one' | 'all' }) + .then(refresh); }; - if (!isVisible) - return
; + if (!isVisible) return
; return (
albumItem && openItem(albumItem)} /> @@ -283,19 +291,12 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
- + {displayArtist && ( <> {' – '} {artistItem ? ( - ) : ( @@ -307,16 +308,8 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
{elapsedLabel} -
-
+
+
{durationLabel}
@@ -326,7 +319,7 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) {/* Centre — transport */}
@@ -392,17 +379,13 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) )} -
diff --git a/renderer/src/components/PlaylistPanel.tsx b/apps/desktop/renderer/src/components/PlaylistPanel.tsx similarity index 76% rename from renderer/src/components/PlaylistPanel.tsx rename to apps/desktop/renderer/src/components/PlaylistPanel.tsx index e184630b..28e972e2 100644 --- a/renderer/src/components/PlaylistPanel.tsx +++ b/apps/desktop/renderer/src/components/PlaylistPanel.tsx @@ -72,10 +72,7 @@ function TrackRow({ {index + 1}
- {art - ? - :
- } + {art ? :
}
{trackName}
@@ -85,16 +82,37 @@ function TrackRow({ {track.addedBy && ( )}
- + {isOwner && ( - + )}
@@ -132,27 +150,30 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { if (index !== dragSrcIndex.current) setDragOverIndex(index); }, []); - const handleDrop = useCallback(async (index: number, e: React.DragEvent) => { - e.preventDefault(); - const raw = e.dataTransfer.getData('application/playlist-track-index'); - setDragOverIndex(null); - if (!raw || !playlist || !id) return; - const from = parseInt(raw, 10); - if (from === index) return; - const snapshot = playlist; - const newTracks = [...playlist.tracks]; - const [moved] = newTracks.splice(from, 1); - newTracks.splice(index, 0, moved); - queryClient.setQueryData(['playlist', id], { ...playlist, tracks: newTracks }); - try { - await window.sonos.reorderPlaylistTracks(id, from, index); - queryClient.invalidateQueries({ queryKey: ['playlist', id] }); - queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); - } catch { - queryClient.setQueryData(['playlist', id], snapshot); - showToast('Failed to reorder tracks'); - } - }, [playlist, id, queryClient, displayName]); + const handleDrop = useCallback( + async (index: number, e: React.DragEvent) => { + e.preventDefault(); + const raw = e.dataTransfer.getData('application/playlist-track-index'); + setDragOverIndex(null); + if (!raw || !playlist || !id) return; + const from = parseInt(raw, 10); + if (from === index) return; + const snapshot = playlist; + const newTracks = [...playlist.tracks]; + const [moved] = newTracks.splice(from, 1); + newTracks.splice(index, 0, moved); + queryClient.setQueryData(['playlist', id], { ...playlist, tracks: newTracks }); + try { + await window.sonos.reorderPlaylistTracks(id, from, index); + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + queryClient.setQueryData(['playlist', id], snapshot); + showToast('Failed to reorder tracks'); + } + }, + [playlist, id, queryClient, displayName] + ); const handleDragEnd = useCallback(() => { setDragOverIndex(null); @@ -186,7 +207,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { async function handleImageUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file || !id) return; - if (file.size > 5 * 1024 * 1024) { alert('Image must be under 5 MB'); return; } + if (file.size > 5 * 1024 * 1024) { + alert('Image must be under 5 MB'); + return; + } setUploading(true); try { const buffer = await file.arrayBuffer(); @@ -226,7 +250,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { async function commitRename() { const trimmed = renameValue.trim(); - if (!trimmed || !id || !playlist || trimmed === playlist.name) { setRenaming(false); return; } + if (!trimmed || !id || !playlist || trimmed === playlist.name) { + setRenaming(false); + return; + } setRenaming(false); const snapshot = playlist; queryClient.setQueryData(['playlist', id], { ...playlist, name: trimmed }); @@ -260,14 +287,19 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) {
isOwner && fileInputRef.current?.click()} title={isOwner ? 'Change image' : undefined} > - {coverArtSrc - ? - : (!isLoading && playlist ? playlist.name[0].toUpperCase() : '') - } + {coverArtSrc ? ( + + ) : !isLoading && playlist ? ( + playlist.name[0].toUpperCase() + ) : ( + '' + )} {isOwner && (
{uploading ? '…' : '📷'} @@ -292,9 +324,21 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { ref={renameInputRef} className={styles.renameInput} value={renameValue} - onChange={e => setRenameValue(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') { cancelRename.current = true; setRenaming(false); } }} - onBlur={() => { if (cancelRename.current) { cancelRename.current = false; return; } commitRename(); }} + onChange={(e) => setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + cancelRename.current = true; + setRenaming(false); + } + }} + onBlur={() => { + if (cancelRename.current) { + cancelRename.current = false; + return; + } + commitRename(); + }} autoFocus /> ) : ( @@ -318,11 +362,15 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { {playlist.isPublic && ( <> · - {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + + {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + )} · - {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} + + {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} + {playlist.createdAt && ( <> · @@ -332,26 +380,31 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) {
{playlist.tracks.length > 0 && ( - + )} {playlist.isPublic && !isOwner && ( - )} - {isOwner && !playlist.isFavourites && ( - confirmDelete ? ( + {isOwner && + !playlist.isFavourites && + (confirmDelete ? ( <> - - + + ) : ( - - ) - )} + + ))}
) : ( @@ -366,7 +419,11 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { {isLoading ? ( <>
- #TitleAdded by + # + + Title + Added by +
{Array.from({ length: 6 }).map((_, i) => (
@@ -400,10 +457,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { canReorder={canReorder} onAdd={() => onAddToQueue(trackToSonosItem(t), -1)} onRemove={() => handleRemoveTrack(t.uri, i)} - onContextMenu={e => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} - onDragStart={e => handleDragStart(i, e)} - onDragOver={e => handleDragOver(i, e)} - onDrop={e => handleDrop(i, e)} + onContextMenu={(e) => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} + onDragStart={(e) => handleDragStart(i, e)} + onDragOver={(e) => handleDragOver(i, e)} + onDrop={(e) => handleDrop(i, e)} onDragEnd={handleDragEnd} />
diff --git a/renderer/src/components/ProfilePanel.tsx b/apps/desktop/renderer/src/components/ProfilePanel.tsx similarity index 57% rename from renderer/src/components/ProfilePanel.tsx rename to apps/desktop/renderer/src/components/ProfilePanel.tsx index efd7d8f0..ef403684 100644 --- a/renderer/src/components/ProfilePanel.tsx +++ b/apps/desktop/renderer/src/components/ProfilePanel.tsx @@ -16,21 +16,65 @@ import { PlaylistCard } from './common/PlaylistCard'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ProfilePanel.module.css'; -function TrackSubtitle({ t, openItem, linkClass }: { t: StatsTrack; openItem: (item: SonosItem) => void; linkClass: string }) { +function TrackSubtitle({ + t, + openItem, + linkClass, +}: { + t: StatsTrack; + openItem: (item: SonosItem) => void; + linkClass: string; +}) { const artistItem: SonosItem | null = t.artistId - ? { type: 'ARTIST', name: t.artist, resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } } } - : null; - const albumItem: SonosItem | null = t.albumId && t.album - ? { type: 'ALBUM', name: t.album, resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } } } + ? { + type: 'ARTIST', + name: t.artist, + resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } }, + } : null; + const albumItem: SonosItem | null = + t.albumId && t.album + ? { + type: 'ALBUM', + name: t.album, + resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } }, + } + : null; return ( <> - {artistItem - ? - : t.artist} - {t.album && <>{' · '}{albumItem - ? - : t.album}} + {artistItem ? ( + + ) : ( + t.artist + )} + {t.album && ( + <> + {' · '} + {albumItem ? ( + + ) : ( + t.album + )} + + )} ); } @@ -75,11 +119,16 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut, onChangeNam const [renameError, setRenameError] = useState(null); const [renameSaving, setRenameSaving] = useState(false); -const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = - useUserStats(userName, 25); + const { + topTracks, + artistItems: topArtists, + albumItems: topAlbums, + totalEvents, + isLoading: statsLoading, + } = useUserStats(userName, 25); const { data: rankings } = useGameRankings(userName, !!userName); - const ranking = rankings?.find(r => r.userName === userName) ?? null; + const ranking = rankings?.find((r) => r.userName === userName) ?? null; const isOwnProfile = !!displayName && displayName === userName; const { data: profile } = useUserProfile(userName); @@ -90,29 +139,52 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, const [trackSelected, setTrackSelected] = useState>(new Set()); const lastTrackSelected = useRef(null); - const handleTrackClick = useCallback((idx: number, e: React.MouseEvent) => { - e.stopPropagation(); - if (e.shiftKey && lastTrackSelected.current !== null) { - const lo = Math.min(lastTrackSelected.current, idx); - const hi = Math.max(lastTrackSelected.current, idx); - setTrackSelected(prev => { const next = new Set(prev); for (let j = lo; j <= hi; j++) next.add(j); return next; }); - } else if (e.ctrlKey || e.metaKey) { - setTrackSelected(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); - lastTrackSelected.current = idx; - } else { - if (trackSelected.size === 1 && trackSelected.has(idx)) { setTrackSelected(new Set()); lastTrackSelected.current = null; } - else { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } - } - }, [trackSelected]); + const handleTrackClick = useCallback( + (idx: number, e: React.MouseEvent) => { + e.stopPropagation(); + if (e.shiftKey && lastTrackSelected.current !== null) { + const lo = Math.min(lastTrackSelected.current, idx); + const hi = Math.max(lastTrackSelected.current, idx); + setTrackSelected((prev) => { + const next = new Set(prev); + for (let j = lo; j <= hi; j++) next.add(j); + return next; + }); + } else if (e.ctrlKey || e.metaKey) { + setTrackSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + lastTrackSelected.current = idx; + } else { + if (trackSelected.size === 1 && trackSelected.has(idx)) { + setTrackSelected(new Set()); + lastTrackSelected.current = null; + } else { + setTrackSelected(new Set([idx])); + lastTrackSelected.current = idx; + } + } + }, + [trackSelected] + ); - const handleTrackDragStart = useCallback((idx: number, e: React.DragEvent) => { - const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; - if (!trackSelected.has(idx)) { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } - const items = indices.map(i => statsTrackToSonosItem(topTracks[i])); - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); - createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); - }, [trackSelected, topTracks]); + const handleTrackDragStart = useCallback( + (idx: number, e: React.DragEvent) => { + const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; + if (!trackSelected.has(idx)) { + setTrackSelected(new Set([idx])); + lastTrackSelected.current = idx; + } + const items = indices.map((i) => statsTrackToSonosItem(topTracks[i])); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); + createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); + }, + [trackSelected, topTracks] + ); async function handleAvatarUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -147,7 +219,7 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); const allPlaylists = isOwnProfile ? [...ownedPlaylists, ...joinedPlaylists] - : ownedPlaylists.filter(p => p.isPublic); + : ownedPlaylists.filter((p) => p.isPublic); const visiblePlaylists = [...allPlaylists].sort((a, b) => { if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; return b.updatedAt - a.updatedAt; @@ -167,10 +239,7 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, onClick={isOwnProfile ? () => fileInputRef.current?.click() : undefined} title={isOwnProfile ? 'Change photo' : undefined} > - {profileArt - ? - : userName[0].toUpperCase() - } + {profileArt ? : userName[0].toUpperCase()} {isOwnProfile && (
{uploading ? '…' : '📷'} @@ -191,7 +260,10 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, { setRenameValue(e.target.value); setRenameError(null); }} + onChange={(e) => { + setRenameValue(e.target.value); + setRenameError(null); + }} autoFocus maxLength={32} spellCheck={false} @@ -199,7 +271,14 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, - {renameError && {renameError}} @@ -211,18 +290,30 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, {(totalEvents > 0 || isOwnProfile) && ( {totalEvents > 0 && ( - <>{totalEvents.toLocaleString()} plays + <> + {totalEvents.toLocaleString()} plays + )} {isOwnProfile && onChangeName && !renaming && ( <> {totalEvents > 0 && |} - + )} {isOwnProfile && onSignOut && ( <> | - + )} @@ -232,14 +323,19 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,
{tierIcon && }
- - {ranking.isProvisional ? 'Provisional' : ranking.tierName} - + {ranking.isProvisional ? 'Provisional' : ranking.tierName} - {ranking.isProvisional - ? <>{ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played - : <>{ranking.averagePercent.toFixed(0)}% avg · best {ranking.bestTotal} · {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} - } + {ranking.isProvisional ? ( + <> + {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played + + ) : ( + <> + {ranking.averagePercent.toFixed(0)}% avg ·{' '} + best {ranking.bestTotal} · {ranking.gamesPlayed}{' '} + {ranking.gamesPlayed === 1 ? 'game' : 'games'} + + )}
@@ -252,47 +348,60 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,

Top Tracks

{ setTrackSelected(new Set()); lastTrackSelected.current = null; }} + onClick={() => { + setTrackSelected(new Set()); + lastTrackSelected.current = null; + }} >
{statsLoading ? Array.from({ length: 5 }).map((_, i) => ( -
- )) - : topTracks.slice(0, 5).map((t, i) => ( - } - artUrl={t.imageUrl} - trailing={{t.count}×} - isSelected={trackSelected.has(i)} - onClick={e => handleTrackClick(i, e)} - draggable={!!t.uri} - onDragStart={t.uri ? e => handleTrackDragStart(i, e) : undefined} + className={styles.skelBlock} + style={{ height: 52, marginBottom: 2, animationDelay: `${i * 60}ms` }} /> )) - } + : topTracks + .slice(0, 5) + .map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i)} + onClick={(e) => handleTrackClick(i, e)} + draggable={!!t.uri} + onDragStart={t.uri ? (e) => handleTrackDragStart(i, e) : undefined} + /> + ))}
{statsLoading ? Array.from({ length: 5 }).map((_, i) => ( -
- )) - : topTracks.slice(5, 10).map((t, i) => ( - } - artUrl={t.imageUrl} - trailing={{t.count}×} - isSelected={trackSelected.has(i + 5)} - onClick={e => handleTrackClick(i + 5, e)} - draggable={!!t.uri} - onDragStart={t.uri ? e => handleTrackDragStart(i + 5, e) : undefined} +
)) - } + : topTracks + .slice(5, 10) + .map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i + 5)} + onClick={(e) => handleTrackClick(i + 5, e)} + draggable={!!t.uri} + onDragStart={t.uri ? (e) => handleTrackDragStart(i + 5, e) : undefined} + /> + ))}
@@ -302,7 +411,13 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, {(statsLoading || topArtists.length > 0) && (

Top Artists

- +
)} @@ -320,7 +435,9 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,

Playlists

{isOwnProfile && ( - + )}
diff --git a/renderer/src/components/Splash.tsx b/apps/desktop/renderer/src/components/Splash.tsx similarity index 60% rename from renderer/src/components/Splash.tsx rename to apps/desktop/renderer/src/components/Splash.tsx index 15c7db49..8961694a 100644 --- a/renderer/src/components/Splash.tsx +++ b/apps/desktop/renderer/src/components/Splash.tsx @@ -8,18 +8,23 @@ interface Props { const MIN_MS = 900; export function Splash({ ready }: Props) { - const [fading, setFading] = useState(false); - const [gone, setGone] = useState(false); + const [fading, setFading] = useState(false); + const [gone, setGone] = useState(false); const mountRef = useRef(0); - useLayoutEffect(() => { mountRef.current = Date.now(); }, []); + useLayoutEffect(() => { + mountRef.current = Date.now(); + }, []); useEffect(() => { if (!ready) return; - const elapsed = Date.now() - mountRef.current; + const elapsed = Date.now() - mountRef.current; const remaining = Math.max(0, MIN_MS - elapsed); - const t1 = setTimeout(() => setFading(true), remaining); - const t2 = setTimeout(() => setGone(true), remaining + 450); - return () => { clearTimeout(t1); clearTimeout(t2); }; + const t1 = setTimeout(() => setFading(true), remaining); + const t2 = setTimeout(() => setGone(true), remaining + 450); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; }, [ready]); if (gone) return null; @@ -30,7 +35,9 @@ export function Splash({ ready }: Props) {
♫
True Tunes
- + + +
diff --git a/renderer/src/components/TopNav.tsx b/apps/desktop/renderer/src/components/TopNav.tsx similarity index 94% rename from renderer/src/components/TopNav.tsx rename to apps/desktop/renderer/src/components/TopNav.tsx index 9510841c..381d646d 100644 --- a/renderer/src/components/TopNav.tsx +++ b/apps/desktop/renderer/src/components/TopNav.tsx @@ -214,10 +214,21 @@ export function TopNav({ title="My profile" > - {profileArt - ? - : displayName[0].toUpperCase() - } + {profileArt ? ( + + ) : ( + displayName[0].toUpperCase() + )} {displayName} @@ -246,11 +257,7 @@ export function TopNav({ spellCheck={false} autoFocus /> -
@@ -314,11 +321,9 @@ export function TopNav({ )} -
- ); } diff --git a/renderer/src/components/WindowControls.tsx b/apps/desktop/renderer/src/components/WindowControls.tsx similarity index 93% rename from renderer/src/components/WindowControls.tsx rename to apps/desktop/renderer/src/components/WindowControls.tsx index 55d0eb3a..8e10d1c1 100644 --- a/renderer/src/components/WindowControls.tsx +++ b/apps/desktop/renderer/src/components/WindowControls.tsx @@ -7,7 +7,10 @@ export function WindowControls() { const [updateVersion, setUpdateVersion] = useState(null); useEffect(() => { - window.sonos.isWindowMaximized().then(setIsMaximized).catch(() => {}); + window.sonos + .isWindowMaximized() + .then(setIsMaximized) + .catch(() => {}); return window.sonos.onWindowMaximized(setIsMaximized); }, []); diff --git a/renderer/src/components/__tests__/CardRow.test.tsx b/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx similarity index 82% rename from renderer/src/components/__tests__/CardRow.test.tsx rename to apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx index a66bdfa4..e2c3c1ea 100644 --- a/renderer/src/components/__tests__/CardRow.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx @@ -13,7 +13,7 @@ const makeItem = (name: string, type: string): SonosItem => ({ resource: { type, id: { objectId: name, serviceId: 'gm', accountId: 'acc' } }, }); -const onAdd = vi.fn(); +const onAdd = vi.fn(); const onOpen = vi.fn(); beforeEach(() => { @@ -23,9 +23,7 @@ beforeEach(() => { describe('CardRow', () => { describe('loading state', () => { it('renders exactly 6 placeholder divs when loading', () => { - const { container } = render( - - ); + const { container } = render(); const placeholders = container.querySelectorAll('.placeholder'); expect(placeholders).toHaveLength(6); }); @@ -41,9 +39,7 @@ describe('CardRow', () => { describe('empty state', () => { it('renders nothing when items is empty and not loading', () => { - const { container } = render( - - ); + const { container } = render(); expect(container.firstChild).toBeNull(); }); }); @@ -60,9 +56,7 @@ describe('CardRow', () => { describe('onAdd conditional', () => { it('shows add button for a TRACK item (non-container)', async () => { const user = userEvent.setup(); - render( - - ); + render(); const addBtn = screen.getByRole('button', { name: '+' }); await user.click(addBtn); expect(onAdd).toHaveBeenCalledOnce(); @@ -79,9 +73,7 @@ describe('CardRow', () => { describe('onOpen conditional', () => { it('makes an ALBUM card clickable (onOpen passed)', async () => { const user = userEvent.setup(); - render( - - ); + render(); // MediaCard renders an onClick on its root div when onOpen is provided const card = screen.getByText('Thriller').closest('.card') as HTMLElement; await user.click(card); @@ -90,9 +82,7 @@ describe('CardRow', () => { it('does not make a plain TRACK card openable', async () => { const user = userEvent.setup(); - render( - - ); + render(); const card = screen.getByText('Lose Yourself').closest('.card') as HTMLElement; await user.click(card); expect(onOpen).not.toHaveBeenCalled(); diff --git a/renderer/src/components/__tests__/ChangelogDialog.test.tsx b/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx similarity index 92% rename from renderer/src/components/__tests__/ChangelogDialog.test.tsx rename to apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx index b7718320..302ec1d7 100644 --- a/renderer/src/components/__tests__/ChangelogDialog.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx @@ -8,8 +8,20 @@ const mockFetch = vi.fn(); (globalThis as any).fetch = mockFetch; const releases = [ - { tag_name: 'v1.2.0', name: 'Version 1.2.0', body: '## Changes\n- Feature A', published_at: '2024-03-01T00:00:00Z', html_url: 'https://github.com/releases/1' }, - { tag_name: 'v1.1.0', name: 'Version 1.1.0', body: null, published_at: '2024-02-01T00:00:00Z', html_url: 'https://github.com/releases/2' }, + { + tag_name: 'v1.2.0', + name: 'Version 1.2.0', + body: '## Changes\n- Feature A', + published_at: '2024-03-01T00:00:00Z', + html_url: 'https://github.com/releases/1', + }, + { + tag_name: 'v1.1.0', + name: 'Version 1.1.0', + body: null, + published_at: '2024-02-01T00:00:00Z', + html_url: 'https://github.com/releases/2', + }, ]; beforeEach(() => { diff --git a/renderer/src/components/__tests__/ContainerPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/ContainerPanel.test.tsx similarity index 100% rename from renderer/src/components/__tests__/ContainerPanel.test.tsx rename to apps/desktop/renderer/src/components/__tests__/ContainerPanel.test.tsx diff --git a/renderer/src/components/__tests__/DiscoverPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx similarity index 92% rename from renderer/src/components/__tests__/DiscoverPanel.test.tsx rename to apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx index 69cbd47d..f2f4e158 100644 --- a/renderer/src/components/__tests__/DiscoverPanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx @@ -47,7 +47,7 @@ function renderPanel() { return render( - , + ); } @@ -55,7 +55,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(window.sonos.fetchDjSet).mockResolvedValue(djSet); vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: [], basis: 'none', observed: 0, detail: [], + inOffice: [], + basis: 'none', + observed: 0, + detail: [], }); vi.mocked(window.sonos.fetchUsers).mockResolvedValue([ { userId: 'Rich', lastQueued: 1, imageUrl: 'https://example.test/rich.png' }, @@ -85,11 +88,9 @@ describe('DiscoverPanel', () => { await userEvent.click(screen.getAllByTitle('Add to queue')[0]); expect(onAddToQueue).toHaveBeenCalledTimes(1); - expect(onAddToQueue).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Left Hand Free' }), - -1, - { attribute: false }, - ); + expect(onAddToQueue).toHaveBeenCalledWith(expect.objectContaining({ title: 'Left Hand Free' }), -1, { + attribute: false, + }); }); it('never attributes picks queued in bulk', async () => { @@ -143,9 +144,7 @@ describe('DiscoverPanel', () => { renderPanel(); // The tab is Discover — its default view should be the music the office // hasn't played, with favourites available as an aside. - expect( - await screen.findByText(/Nothing new found|Digging for something new/), - ).toBeInTheDocument(); + expect(await screen.findByText(/Nothing new found|Digging for something new/)).toBeInTheDocument(); }); it('splits the room from everyone else', async () => { @@ -170,13 +169,16 @@ describe('DiscoverPanel', () => { it('marks which of them is you', async () => { vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: ['Rich'], basis: 'sensed', observed: 1, detail: [], + inOffice: ['Rich'], + basis: 'sensed', + observed: 1, + detail: [], }); render( - , + ); expect(await screen.findByText('you')).toBeInTheDocument(); @@ -184,7 +186,10 @@ describe('DiscoverPanel', () => { it('says so plainly when someone has no Teams presence at all', async () => { vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: [], basis: 'none', observed: 0, detail: [], + inOffice: [], + basis: 'none', + observed: 0, + detail: [], }); renderPanel(); diff --git a/renderer/src/components/__tests__/DisplayNameModal.test.tsx b/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx similarity index 92% rename from renderer/src/components/__tests__/DisplayNameModal.test.tsx rename to apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx index b2fdd4ec..fdaaa960 100644 --- a/renderer/src/components/__tests__/DisplayNameModal.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx @@ -6,10 +6,10 @@ import { DisplayNameModal } from '../DisplayNameModal'; describe('DisplayNameModal', () => { it('renders the heading', () => { render(); - expect(screen.getByText("What should we call you?")).toBeInTheDocument(); + expect(screen.getByText('What should we call you?')).toBeInTheDocument(); }); - it("submit button is disabled when input is empty", () => { + it('submit button is disabled when input is empty', () => { render(); expect(screen.getByRole('button', { name: "Let's go" })).toBeDisabled(); }); diff --git a/renderer/src/components/__tests__/ErrorBoundary.test.tsx b/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx similarity index 89% rename from renderer/src/components/__tests__/ErrorBoundary.test.tsx rename to apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx index c29a7e88..dd528392 100644 --- a/renderer/src/components/__tests__/ErrorBoundary.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx @@ -51,8 +51,11 @@ describe('ErrorBoundary', () => { ); - expect(window.sonos.trackEvent).toHaveBeenCalledWith('renderer_crash', expect.objectContaining({ - message: 'Test crash', - })); + expect(window.sonos.trackEvent).toHaveBeenCalledWith( + 'renderer_crash', + expect.objectContaining({ + message: 'Test crash', + }) + ); }); }); diff --git a/renderer/src/components/__tests__/FeedbackDialog.test.tsx b/apps/desktop/renderer/src/components/__tests__/FeedbackDialog.test.tsx similarity index 100% rename from renderer/src/components/__tests__/FeedbackDialog.test.tsx rename to apps/desktop/renderer/src/components/__tests__/FeedbackDialog.test.tsx diff --git a/renderer/src/components/__tests__/HomePanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx similarity index 95% rename from renderer/src/components/__tests__/HomePanel.test.tsx rename to apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx index 7268ac76..fb18cc20 100644 --- a/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx @@ -46,7 +46,11 @@ vi.mock('../../hooks/useRecentlyPlayed', () => ({ function makeWrapper() { return ({ children }: { children: React.ReactNode }) => - createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } function makeRootData(rootItems: SonosItem[]) { @@ -101,17 +105,17 @@ describe('fetchYtmSections', () => { }); it('browses Home, New releases, Charts when items are present', async () => { - const homeItem = makeRootItem('Home', 'home-id'); - const nrItem = makeRootItem('New releases', 'nr-id'); - const chartsItem = makeRootItem('Charts', 'charts-id'); + const homeItem = makeRootItem('Home', 'home-id'); + const nrItem = makeRootItem('New releases', 'nr-id'); + const chartsItem = makeRootItem('Charts', 'charts-id'); mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem, nrItem, chartsItem]) }) .mockResolvedValue({ error: null, data: { items: [] } }); await fetchYtmSections(); - expect(mockBrowseContainer).toHaveBeenCalledWith('home-id', expect.anything()); - expect(mockBrowseContainer).toHaveBeenCalledWith('nr-id', expect.anything()); + expect(mockBrowseContainer).toHaveBeenCalledWith('home-id', expect.anything()); + expect(mockBrowseContainer).toHaveBeenCalledWith('nr-id', expect.anything()); expect(mockBrowseContainer).toHaveBeenCalledWith('charts-id', expect.anything()); }); @@ -129,7 +133,7 @@ describe('fetchYtmSections', () => { it('prepends supermix item to forYou when present', async () => { const supermixItem = makeRootItem('My Supermix', 'sm-id'); - const homeItem = makeRootItem('Home', 'home-id'); + const homeItem = makeRootItem('Home', 'home-id'); const track: SonosItem = { name: 'Home Track', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([supermixItem, homeItem]) }) diff --git a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/LeaderboardPanel.test.tsx similarity index 100% rename from renderer/src/components/__tests__/LeaderboardPanel.test.tsx rename to apps/desktop/renderer/src/components/__tests__/LeaderboardPanel.test.tsx diff --git a/renderer/src/components/__tests__/MiniPlayer.test.tsx b/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx similarity index 87% rename from renderer/src/components/__tests__/MiniPlayer.test.tsx rename to apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx index 3fb74c90..9a0a1b06 100644 --- a/renderer/src/components/__tests__/MiniPlayer.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx @@ -8,17 +8,31 @@ import { MiniPlayerShell } from '../MiniPlayer'; // Mock all the hooks that MiniPlayerShell pulls in const mockUseNowPlaying = vi.fn(); vi.mock('../../hooks/useNowPlaying', () => ({ useNowPlaying: (...a: unknown[]) => mockUseNowPlaying(...a) })); -vi.mock('../../hooks/useAuth', () => ({ useAuth: () => true })); -vi.mock('../../hooks/useGroups', () => ({ useGroups: () => [] })); +vi.mock('../../hooks/useAuth', () => ({ useAuth: () => true })); +vi.mock('../../hooks/useGroups', () => ({ useGroups: () => [] })); vi.mock('../../hooks/usePlayback', () => ({ usePlayback: () => ({ playback: { - isVisible: true, trackName: '', artistName: '', artUrl: null, - stateIcon: '▶', timeLabel: '', progressPct: 0, durationMs: 0, - isPlaying: false, shuffle: false, repeat: 'none', volume: 50, - currentObjectId: null, currentServiceId: null, currentAccountId: null, - currentAlbumId: null, currentAlbumName: null, isExplicit: false, - queueId: null, queueVersion: null, + isVisible: true, + trackName: '', + artistName: '', + artUrl: null, + stateIcon: '▶', + timeLabel: '', + progressPct: 0, + durationMs: 0, + isPlaying: false, + shuffle: false, + repeat: 'none', + volume: 50, + currentObjectId: null, + currentServiceId: null, + currentAccountId: null, + currentAlbumId: null, + currentAlbumName: null, + isExplicit: false, + queueId: null, + queueVersion: null, }, }), })); diff --git a/renderer/src/components/__tests__/PlayerBar.test.tsx b/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx similarity index 92% rename from renderer/src/components/__tests__/PlayerBar.test.tsx rename to apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx index 0614eabf..85bf31c2 100644 --- a/renderer/src/components/__tests__/PlayerBar.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx @@ -117,17 +117,11 @@ beforeEach(() => { describe('PlayerBar — visibility', () => { it('renders nothing visible when isVisible is false', () => { - vi.mocked(useNowPlaying).mockReturnValue( - makeNowPlaying({ isVisible: false }) as ReturnType - ); + vi.mocked(useNowPlaying).mockReturnValue(makeNowPlaying({ isVisible: false }) as ReturnType); const qc = new QueryClient(); const { container } = render( - + ); expect(container.querySelector('[class*="bar"]')).toBeNull(); @@ -210,17 +204,13 @@ describe('PlayerBar — shuffle', () => { it('clicking Shuffle calls setPlayModes with toggled value', async () => { const { user } = setup({ shuffle: false }); await user.click(screen.getByTitle('Shuffle')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: true }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: true })); }); it('toggling shuffle off calls setPlayModes with false', async () => { const { user } = setup({ shuffle: true }); await user.click(screen.getByTitle('Shuffle')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: false })); }); it('shuffle button calls onShuffle prop', async () => { @@ -237,25 +227,19 @@ describe('PlayerBar — repeat', () => { it('clicking Repeat when off sets to all', async () => { const { user } = setup({ repeat: 'none' }); await user.click(screen.getByTitle('Repeat off')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: true, repeatOne: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: true, repeatOne: false })); }); it('clicking Repeat when all sets to one', async () => { const { user } = setup({ repeat: 'all' }, { repeat: 'all' }); await user.click(screen.getByTitle('Repeat all')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: true }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: true })); }); it('clicking Repeat when one sets to none', async () => { const { user } = setup({ repeat: 'one' }, { repeat: 'one' }); await user.click(screen.getByTitle('Repeat one')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: false })); }); }); @@ -272,9 +256,7 @@ describe('PlayerBar — seek bar', () => { act(() => { bar.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100 })); }); - await waitFor(() => - expect(window.sonos.seek).toHaveBeenCalledWith(100000) - ); + await waitFor(() => expect(window.sonos.seek).toHaveBeenCalledWith(100000)); expect(window.sonos.refreshPlayback).toHaveBeenCalled(); }); @@ -287,9 +269,7 @@ describe('PlayerBar — seek bar', () => { act(() => { bar.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 50 })); }); - await waitFor(() => - expect(window.sonos.seek).toHaveBeenCalledWith(0) - ); + await waitFor(() => expect(window.sonos.seek).toHaveBeenCalledWith(0)); }); it('does not seek when durationMs is 0', async () => { diff --git a/renderer/src/components/__tests__/PlaylistPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx similarity index 97% rename from renderer/src/components/__tests__/PlaylistPanel.test.tsx rename to apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx index ef2f0a5d..b78ce32e 100644 --- a/renderer/src/components/__tests__/PlaylistPanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx @@ -70,8 +70,8 @@ function renderPanel(qc: QueryClient) { createElement(PlaylistPanel, { displayName: OWNER, onAddToQueue: vi.fn() as (item: SonosItem, position?: number) => void, - }), - ), + }) + ) ); } @@ -95,10 +95,7 @@ describe('handleRemoveTrack', () => { fireEvent.click(removeBtn); await waitFor(() => { - expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith( - PLAYLIST_ID, - TRACK_URI, - ); + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith(PLAYLIST_ID, TRACK_URI); }); const updated = qc.getQueryData(['playlist', PLAYLIST_ID]); diff --git a/renderer/src/components/__tests__/Splash.test.tsx b/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx similarity index 92% rename from renderer/src/components/__tests__/Splash.test.tsx rename to apps/desktop/renderer/src/components/__tests__/Splash.test.tsx index 61e4ad7d..dc92d3ef 100644 --- a/renderer/src/components/__tests__/Splash.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx @@ -17,7 +17,9 @@ describe('Splash', () => { vi.useFakeTimers(); const { container } = render(); // After 900ms remaining + 450ms fade - act(() => { vi.advanceTimersByTime(1400); }); + act(() => { + vi.advanceTimersByTime(1400); + }); expect(container.firstChild).toBeNull(); vi.useRealTimers(); }); diff --git a/renderer/src/components/__tests__/TopNav.test.tsx b/apps/desktop/renderer/src/components/__tests__/TopNav.test.tsx similarity index 100% rename from renderer/src/components/__tests__/TopNav.test.tsx rename to apps/desktop/renderer/src/components/__tests__/TopNav.test.tsx diff --git a/renderer/src/components/album/AlbumPanel.tsx b/apps/desktop/renderer/src/components/album/AlbumPanel.tsx similarity index 74% rename from renderer/src/components/album/AlbumPanel.tsx rename to apps/desktop/renderer/src/components/album/AlbumPanel.tsx index 2903612e..d8c15601 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/apps/desktop/renderer/src/components/album/AlbumPanel.tsx @@ -25,7 +25,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { const queryClient = useQueryClient(); const { resolveAndOpen } = useResolveAndOpen(); - const isPlaylistOrProgram = item ? (isPlaylist(item) || isProgram(item)) : false; + const isPlaylistOrProgram = item ? isPlaylist(item) || isProgram(item) : false; const { albumId, serviceId, accountId, defaults } = item ? resolveAlbumParams(item) : { albumId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined }; @@ -33,14 +33,21 @@ export function AlbumPanel({ onAddToQueue }: Props) { const lastSelected = useRef(null); const [adding, setAdding] = useState(false); - const albumResult = useAlbumBrowse(isPlaylistOrProgram ? undefined : albumId, serviceId, accountId, defaults); + const albumResult = useAlbumBrowse(isPlaylistOrProgram ? undefined : albumId, serviceId, accountId, defaults); const playlistResult = usePlaylistBrowse(isPlaylistOrProgram ? albumId : undefined, serviceId, accountId, defaults); const isLoading = isPlaylistOrProgram ? playlistResult.isLoading : albumResult.isLoading; - const error = isPlaylistOrProgram ? playlistResult.error : albumResult.error; + const error = isPlaylistOrProgram ? playlistResult.error : albumResult.error; const data = isPlaylistOrProgram ? playlistResult.data - ? { title: null, artist: null, artUrl: null, tracks: playlistResult.data, totalTracks: playlistResult.data.length, artistItem: null } + ? { + title: null, + artist: null, + artUrl: null, + tracks: playlistResult.data, + totalTracks: playlistResult.data.length, + artistItem: null, + } : null : (albumResult.data ?? null); @@ -51,15 +58,12 @@ export function AlbumPanel({ onAddToQueue }: Props) { queryClient.prefetchQuery(artistQueryOptions(rid.objectId, rid.serviceId, rid.accountId, undefined)); }, [queryClient, data?.artistItem]); - const title = data?.title ?? item?.title ?? item?.name ?? ''; + const title = data?.title ?? item?.title ?? item?.name ?? ''; const artist = data?.artist ?? ((item as Record)?.['subtitle'] as string) ?? ''; const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); - const cachedArt = useImage(artUrl); + const cachedArt = useImage(artUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); - const year = useGeniusAlbumYear( - isPlaylistOrProgram ? null : title, - isPlaylistOrProgram ? null : artist, - ); + const year = useGeniusAlbumYear(isPlaylistOrProgram ? null : title, isPlaylistOrProgram ? null : artist); useEffect(() => { setSelected(new Set()); @@ -68,30 +72,47 @@ export function AlbumPanel({ onAddToQueue }: Props) { if (!item) return null; - const trackServiceId = (data?.tracks[0]?.id as SonosItemId | undefined)?.serviceId ?? serviceId ?? ''; - const trackAccountId = (data?.tracks[0]?.id as SonosItemId | undefined)?.accountId ?? accountId ?? ''; + const trackServiceId = (data?.tracks[0]?.id as SonosItemId | undefined)?.serviceId ?? serviceId ?? ''; + const trackAccountId = (data?.tracks[0]?.id as SonosItemId | undefined)?.accountId ?? accountId ?? ''; function handleTrackClick(index: number, e: React.MouseEvent) { e.stopPropagation(); if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); + setSelected((prev) => { + const next = new Set(prev); + for (let i = lo; i <= hi; i++) next.add(i); + return next; + }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); return next; }); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); lastSelected.current = index; } else { - if (selected.size === 1 && selected.has(index)) { setSelected(new Set()); lastSelected.current = null; } - else { setSelected(new Set([index])); lastSelected.current = index; } + if (selected.size === 1 && selected.has(index)) { + setSelected(new Set()); + lastSelected.current = null; + } else { + setSelected(new Set([index])); + lastSelected.current = index; + } } } function handleTrackDragStart(index: number, e: React.DragEvent) { if (!data) return; const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => data.tracks[idx].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((idx) => data.tracks[idx].raw))); createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title, e.dataTransfer); } @@ -99,7 +120,10 @@ export function AlbumPanel({ onAddToQueue }: Props) { const totalMins = Math.round(totalSecs / 60); const headerStyle = dominantColor - ? { background: `linear-gradient(180deg, rgba(${dominantColor}, 0.55) 0%, rgba(${dominantColor}, 0.2) 60%, transparent 100%)`, transition: 'background 0.8s ease' } + ? { + background: `linear-gradient(180deg, rgba(${dominantColor}, 0.55) 0%, rgba(${dominantColor}, 0.2) 60%, transparent 100%)`, + transition: 'background 0.8s ease', + } : undefined; function openArtist() { @@ -145,7 +169,9 @@ export function AlbumPanel({ onAddToQueue }: Props) { )} {data && (
- {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year].filter(Boolean).join(' \u2022 ')} + {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year] + .filter(Boolean) + .join(' \u2022 ')}
)}
@@ -155,8 +181,11 @@ export function AlbumPanel({ onAddToQueue }: Props) { onClick={async () => { if (!item || !data?.tracks?.length) return; setAdding(true); - try { await onAddToQueue(item); } - finally { setAdding(false); } + try { + await onAddToQueue(item); + } finally { + setAdding(false); + } }} > {adding ? 'Adding…' : '+ Add to Queue'} @@ -168,8 +197,15 @@ export function AlbumPanel({ onAddToQueue }: Props) {
{ setSelected(new Set()); lastSelected.current = null; }} + style={ + { + '--track-cols': isPlaylistOrProgram ? '24px 42px 1fr 260px 260px 50px 28px' : '24px 1fr 160px 50px 28px', + } as React.CSSProperties + } + onClick={() => { + setSelected(new Set()); + lastSelected.current = null; + }} > {data && (
@@ -185,14 +221,18 @@ export function AlbumPanel({ onAddToQueue }: Props) { {isLoading && ( <> -
+
+
+
{Array.from({ length: 10 }).map((_, i) => (
{isPlaylistOrProgram &&
}
- {isPlaylistOrProgram &&
} + {isPlaylistOrProgram && ( +
+ )}
@@ -209,8 +249,8 @@ export function AlbumPanel({ onAddToQueue }: Props) { isSelected={selected.has(i)} serviceId={trackServiceId} accountId={trackAccountId} - onClick={e => handleTrackClick(i, e)} - onDragStart={e => handleTrackDragStart(i, e)} + onClick={(e) => handleTrackClick(i, e)} + onDragStart={(e) => handleTrackDragStart(i, e)} onAdd={() => onAddToQueue(track.raw)} /> ))} diff --git a/renderer/src/components/album/AlbumTrackRow.tsx b/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx similarity index 80% rename from renderer/src/components/album/AlbumTrackRow.tsx rename to apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx index c28e687e..e7a327a7 100644 --- a/renderer/src/components/album/AlbumTrackRow.tsx +++ b/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx @@ -24,8 +24,14 @@ interface Props { } export function AlbumTrackRow({ - track, isPlaylistOrProgram, isSelected, serviceId, accountId, - onClick, onDragStart, onAdd, + track, + isPlaylistOrProgram, + isSelected, + serviceId, + accountId, + onClick, + onDragStart, + onAdd, }: Props) { const navigate = useNavigate(); const { showTrackMenu } = useTrackContextMenu(); @@ -47,7 +53,7 @@ export function AlbumTrackRow({ className={[styles.trackRow, isSelected ? styles.selected : ''].filter(Boolean).join(' ')} draggable onClick={onClick} - onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} + onContextMenu={(e) => showTrackMenu({ track: trackPayload }, e)} onDragStart={onDragStart} > {track.ordinal} @@ -76,8 +82,8 @@ export function AlbumTrackRow({ {ai > 0 && ', '}
- {isPlaylistOrProgram && (() => { - const albumId = track.albumId; - const albumName = track.albumName; - return ( -
- {albumId && albumName - ? ( + {isPlaylistOrProgram && + (() => { + const albumId = track.albumId; + const albumName = track.albumName; + return ( +
+ {albumId && albumName ? ( - ) - : (albumName ?? '')} -
- ); - })()} + ) : ( + (albumName ?? '') + )} +
+ ); + })()} {fmtDuration(track.durationSeconds)} - +
); } diff --git a/renderer/src/components/album/__tests__/AlbumPanel.test.tsx b/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx similarity index 92% rename from renderer/src/components/album/__tests__/AlbumPanel.test.tsx rename to apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx index c76573ee..dc756dfa 100644 --- a/renderer/src/components/album/__tests__/AlbumPanel.test.tsx +++ b/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx @@ -17,9 +17,13 @@ const mockUseImage = vi.fn(); vi.mock('../../../hooks/useImage', () => ({ useImage: (...a: unknown[]) => mockUseImage(...a) })); const mockUseDominantColor = vi.fn(); -vi.mock('../../../hooks/useDominantColor', () => ({ useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a) })); +vi.mock('../../../hooks/useDominantColor', () => ({ + useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a), +})); -vi.mock('../../../hooks/useArtistBrowse', () => ({ artistQueryOptions: vi.fn(() => ({ queryKey: [], queryFn: async () => null })) })); +vi.mock('../../../hooks/useArtistBrowse', () => ({ + artistQueryOptions: vi.fn(() => ({ queryKey: [], queryFn: async () => null })), +})); const mockUseAlbumBrowse = vi.fn(); vi.mock('../../../hooks/useAlbumBrowse', () => ({ @@ -42,13 +46,16 @@ vi.mock('../AlbumTrackRow', () => ({ onClick: (e: React.MouseEvent) => void; onAdd: () => void; }) => ( -
+
{track.title} - +
), })); @@ -100,7 +107,11 @@ function makeAlbumData(overrides = {}) { } function wrapper({ children }: { children: React.ReactNode }) { - return createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + return createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } beforeEach(() => { @@ -150,7 +161,11 @@ describe('AlbumPanel', () => { }); it('shows no tracks message when empty', () => { - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks: [], totalTracks: 0 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks: [], totalTracks: 0 }), + isLoading: false, + error: null, + }); render(, { wrapper }); expect(screen.getByText('No tracks found.')).toBeInTheDocument(); }); @@ -163,7 +178,11 @@ describe('AlbumPanel', () => { it('shows only song count when total duration is 0', () => { const tracks = [makeTrack('Song A', 0, 0), makeTrack('Song B', 1, 0)]; - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks, totalTracks: 2 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks, totalTracks: 2 }), + isLoading: false, + error: null, + }); render(, { wrapper }); const metaLine = screen.getByText(/2 songs/); expect(metaLine.textContent).not.toContain('min'); @@ -237,7 +256,11 @@ describe('AlbumPanel', () => { it('shift+click range-selects from last to target', () => { const tracks = Array.from({ length: 4 }, (_, i) => makeTrack(`Song ${i}`, i)); - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks, totalTracks: 4 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks, totalTracks: 4 }), + isLoading: false, + error: null, + }); render(, { wrapper }); fireEvent.click(screen.getByTestId('trk-0')); fireEvent.click(screen.getByTestId('trk-2'), { shiftKey: true }); diff --git a/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx b/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx similarity index 82% rename from renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx rename to apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx index 8f81803f..4b7ed56c 100644 --- a/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx +++ b/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx @@ -80,36 +80,26 @@ describe('AlbumTrackRow', () => { const playlistProps = { ...baseProps, isPlaylistOrProgram: true }; it('renders artist buttons with navigation', () => { - render(); + render( + + ); const btn = screen.getByRole('button', { name: 'John Lennon' }); fireEvent.click(btn); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); it('renders album link with navigation when albumId and albumName are set', () => { - render(); + render(); const btn = screen.getByRole('button', { name: 'Abbey Road' }); fireEvent.click(btn); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); it('renders albumName as plain text when no albumId', () => { - render(); + render(); expect(screen.getByText('Unknown Album')).toBeInTheDocument(); }); }); diff --git a/renderer/src/components/artist/ArtistAlbumCard.tsx b/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx similarity index 84% rename from renderer/src/components/artist/ArtistAlbumCard.tsx rename to apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx index c55bb688..73a4c899 100644 --- a/renderer/src/components/artist/ArtistAlbumCard.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx @@ -20,7 +20,15 @@ export function ArtistAlbumCard({ album, onOpen, onAdd }: Props) {
{art ? :
♪
}
- +
diff --git a/renderer/src/components/artist/ArtistHero.module.css b/apps/desktop/renderer/src/components/artist/ArtistHero.module.css similarity index 66% rename from renderer/src/components/artist/ArtistHero.module.css rename to apps/desktop/renderer/src/components/artist/ArtistHero.module.css index adf7fed4..65f8c87c 100644 --- a/renderer/src/components/artist/ArtistHero.module.css +++ b/apps/desktop/renderer/src/components/artist/ArtistHero.module.css @@ -19,8 +19,12 @@ transition: background 0.15s; border-bottom: 1px solid var(--border); } -.heroHeader:hover { background: rgba(255, 255, 255, 0.05); } -.heroHeader:hover .heroName { text-decoration: underline; } +.heroHeader:hover { + background: rgba(255, 255, 255, 0.05); +} +.heroHeader:hover .heroName { + text-decoration: underline; +} .heroAvatar { width: 48px; @@ -31,8 +35,17 @@ flex-shrink: 0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } -.heroAvatarImg { width: 100%; height: 100%; object-fit: cover; display: block; } -.heroAvatarPh { width: 100%; height: 100%; background: var(--bg-1); } +.heroAvatarImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.heroAvatarPh { + width: 100%; + height: 100%; + background: var(--bg-1); +} .heroName { font-size: 16px; @@ -64,10 +77,18 @@ cursor: grab; min-width: 0; } -.heroTrackRow:active { cursor: grabbing; } -.heroTrackRow:hover { background: rgba(255,255,255,0.07); } -.heroTrackSelected { background: rgba(255,255,255,0.1) !important; } -.heroTrackSelected:hover { background: rgba(255,255,255,0.13) !important; } +.heroTrackRow:active { + cursor: grabbing; +} +.heroTrackRow:hover { + background: rgba(255, 255, 255, 0.07); +} +.heroTrackSelected { + background: rgba(255, 255, 255, 0.1) !important; +} +.heroTrackSelected:hover { + background: rgba(255, 255, 255, 0.13) !important; +} .heroTrackArt { width: 32px; @@ -77,10 +98,22 @@ overflow: hidden; background: var(--bg-1); } -.heroTrackArt img { width: 100%; height: 100%; object-fit: cover; display: block; } -.heroTrackArtPh { width: 100%; height: 100%; background: var(--bg-2); } +.heroTrackArt img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.heroTrackArtPh { + width: 100%; + height: 100%; + background: var(--bg-2); +} -.heroTrackInfo { flex: 1; min-width: 0; } +.heroTrackInfo { + flex: 1; + min-width: 0; +} .heroTrackName { font-size: 12px; font-weight: 500; @@ -93,7 +126,9 @@ gap: 4px; } -.heroTrackExplicit { margin-left: auto; } +.heroTrackExplicit { + margin-left: auto; +} .heroTrackDur { font-size: 11px; @@ -118,8 +153,14 @@ opacity: 0; transition: all 0.15s; } -.heroTrackRow:hover .heroTrackAdd { opacity: 1; } -.heroTrackAdd:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } +.heroTrackRow:hover .heroTrackAdd { + opacity: 1; +} +.heroTrackAdd:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); +} .heroTrackPh { height: 44px; @@ -129,6 +170,11 @@ } @keyframes pulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.6; + } } diff --git a/renderer/src/components/artist/ArtistHero.tsx b/apps/desktop/renderer/src/components/artist/ArtistHero.tsx similarity index 69% rename from renderer/src/components/artist/ArtistHero.tsx rename to apps/desktop/renderer/src/components/artist/ArtistHero.tsx index 4b6e8b09..e4f63fbd 100644 --- a/renderer/src/components/artist/ArtistHero.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistHero.tsx @@ -24,28 +24,32 @@ export function ArtistHero({ enabled: !!(artistId && serviceId && accountId), }); - const cachedArt = useImage(getItemArt(artist)); + const cachedArt = useImage(getItemArt(artist)); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); - const name = (artist.title ?? artist.name ?? '') as string; + const name = (artist.title ?? artist.name ?? '') as string; - const [selected, setSelected] = useState>(new Set()); - const lastSelected = useRef(null); - const tracks = data?.topSongs?.slice(0, 6) ?? []; + const [selected, setSelected] = useState>(new Set()); + const lastSelected = useRef(null); + const tracks = data?.topSongs?.slice(0, 6) ?? []; function handleClick(index: number, e: React.MouseEvent) { e.stopPropagation(); if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); - if (next.has(index)) { next.delete(index); } else { next.add(index); } + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } return next; }); lastSelected.current = index; @@ -62,29 +66,44 @@ export function ArtistHero({ function handleDragStart(index: number, e: React.DragEvent) { const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => tracks[i].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((i) => tracks[i].raw))); createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw), e.dataTransfer); } return (
{ setSelected(new Set()); lastSelected.current = null; }} + style={ + dominantColor + ? { background: `linear-gradient(135deg, rgba(${dominantColor},0.18) 0%, transparent 70%)` } + : undefined + } + onClick={() => { + setSelected(new Set()); + lastSelected.current = null; + }} >
{ e.stopPropagation(); onOpen(artist); }} + onClick={(e) => { + e.stopPropagation(); + onOpen(artist); + }} role="button" tabIndex={0} - onKeyDown={e => e.key === 'Enter' && onOpen(artist)} + onKeyDown={(e) => e.key === 'Enter' && onOpen(artist)} >
- {cachedArt - ? - :
} + {cachedArt ? ( + + ) : ( +
+ )}
{name}
@@ -102,9 +121,7 @@ export function ArtistHero({ onAdd={() => onAddToQueue(track.raw)} /> )) - : Array.from({ length: 6 }).map((_, i) => ( -
- ))} + : Array.from({ length: 6 }).map((_, i) =>
)}
); diff --git a/renderer/src/components/artist/ArtistPanel.tsx b/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx similarity index 69% rename from renderer/src/components/artist/ArtistPanel.tsx rename to apps/desktop/renderer/src/components/artist/ArtistPanel.tsx index e446a895..8376e748 100644 --- a/renderer/src/components/artist/ArtistPanel.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx @@ -19,11 +19,9 @@ function domToText(node: GeniusDomNode): string { function extractBio(description: GeniusDomNode | null): string { if (!description || typeof description === 'string') return ''; const root = description as { tag: string; children?: GeniusDomNode[] }; - const paras = (root.children ?? []).filter( - c => typeof c !== 'string' && (c as { tag: string }).tag === 'p', - ); + const paras = (root.children ?? []).filter((c) => typeof c !== 'string' && (c as { tag: string }).tag === 'p'); return paras - .map(p => domToText(p as GeniusDomNode).trim()) + .map((p) => domToText(p as GeniusDomNode).trim()) .filter(Boolean) .join('\n\n'); } @@ -39,25 +37,27 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } const item = (state as { item?: SonosItem } | null)?.item; const openItem = useOpenItem(); - const { artistId, serviceId, accountId, defaults, name: fallbackName } = - item - ? resolveArtistParams(item) - : { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined }; + const { + artistId, + serviceId, + accountId, + defaults, + name: fallbackName, + } = item + ? resolveArtistParams(item) + : { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined }; const { data, isLoading } = useArtistBrowse( artistId, serviceId, accountId, defaults, - fallbackName ?? (item?.title as string | undefined) ?? (item?.name as string | undefined), + fallbackName ?? (item?.title as string | undefined) ?? (item?.name as string | undefined) ); const name = data?.name ?? fallbackName ?? item?.title ?? item?.name ?? ''; const imageUrl = - data?.imageUrl ?? - (item?.images as Record | undefined)?.['tile1x1'] ?? - item?.imageUrl ?? - null; + data?.imageUrl ?? (item?.images as Record | undefined)?.['tile1x1'] ?? item?.imageUrl ?? null; const [showAllSongs, setShowAllSongs] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -70,13 +70,13 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); @@ -96,15 +96,26 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } function handleDragStart(index: number, e: React.DragEvent) { const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => visibleSongs[i].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((i) => visibleSongs[i].raw))); const ghost = document.createElement('div'); Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + position: 'fixed', + top: '-100px', + left: '0', + background: 'rgba(255,255,255,0.15)', + backdropFilter: 'blur(8px)', + color: '#fff', + padding: '5px 12px', + borderRadius: '6px', + fontSize: '12px', + fontWeight: '600', + pointerEvents: 'none', + whiteSpace: 'nowrap', }); ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : visibleSongs[index].title; document.body.appendChild(ghost); @@ -112,14 +123,14 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } setTimeout(() => ghost.remove(), 0); } - const cachedArt = useImage(imageUrl); + const cachedArt = useImage(imageUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const latestAlbum = data?.albums[0] ?? null; - const genius = data?.genius ?? null; - const blurb = genius ? extractBio(genius.description) : ''; - const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? []; + const genius = data?.genius ?? null; + const blurb = genius ? extractBio(genius.description) : ''; + const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? []; if (!item) return null; return ( @@ -129,21 +140,24 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } className={styles.header} style={ dominantColor - ? { background: `linear-gradient(180deg, rgba(${dominantColor},0.55) 0%, rgba(${dominantColor},0.2) 60%, transparent 100%)`, transition: 'background 0.8s ease' } + ? { + background: `linear-gradient(180deg, rgba(${dominantColor},0.55) 0%, rgba(${dominantColor},0.2) 60%, transparent 100%)`, + transition: 'background 0.8s ease', + } : undefined } >
- {cachedArt - ? - :
} + {cachedArt ? ( + + ) : ( +
+ )}
{name}
- {altNames.length > 0 && ( -
{altNames.join(' · ')}
- )} + {altNames.length > 0 &&
{altNames.join(' · ')}
}
@@ -152,7 +166,9 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } {isLoading ? ( <>
- {Array.from({ length: 6 }).map((_, i) =>
)} + {Array.from({ length: 6 }).map((_, i) => ( +
+ ))}
@@ -166,7 +182,10 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } <> @@ -192,7 +211,9 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive }
About
{blurb.split('\n\n').map((para, i) => ( -

{para}

+

+ {para} +

))}
)} @@ -200,18 +221,35 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive }
{genius.instagram && ( - +
); } diff --git a/renderer/src/components/artist/LatestReleaseCard.tsx b/apps/desktop/renderer/src/components/artist/LatestReleaseCard.tsx similarity index 100% rename from renderer/src/components/artist/LatestReleaseCard.tsx rename to apps/desktop/renderer/src/components/artist/LatestReleaseCard.tsx diff --git a/renderer/src/components/artist/RadioCard.tsx b/apps/desktop/renderer/src/components/artist/RadioCard.tsx similarity index 100% rename from renderer/src/components/artist/RadioCard.tsx rename to apps/desktop/renderer/src/components/artist/RadioCard.tsx diff --git a/renderer/src/components/artist/TopSongRow.tsx b/apps/desktop/renderer/src/components/artist/TopSongRow.tsx similarity index 82% rename from renderer/src/components/artist/TopSongRow.tsx rename to apps/desktop/renderer/src/components/artist/TopSongRow.tsx index ac838bc8..f8f3e578 100644 --- a/renderer/src/components/artist/TopSongRow.tsx +++ b/apps/desktop/renderer/src/components/artist/TopSongRow.tsx @@ -17,7 +17,16 @@ interface Props { onDragStart: (index: number, e: React.DragEvent) => void; } -export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybackActive, onAdd, onClick, onDragStart }: Props) { +export function TopSongRow({ + track, + index, + isSelected, + isCurrentTrack, + isPlaybackActive, + onAdd, + onClick, + onDragStart, +}: Props) { const art = useImage(track.artUrl); const { showTrackMenu } = useTrackContextMenu(); const subtitle = (track.raw as Record)?.['subtitle'] as string | undefined; @@ -38,9 +47,9 @@ export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybac
onClick(index, e)} - onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} - onDragStart={e => onDragStart(index, e)} + onClick={(e) => onClick(index, e)} + onContextMenu={(e) => showTrackMenu({ track: trackPayload }, e)} + onDragStart={(e) => onDragStart(index, e)} > {isCurrentTrack ? (
@@ -62,7 +71,15 @@ export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybac {subtitle && {subtitle}}
{fmtDuration(track.durationSeconds)} - +
); } diff --git a/renderer/src/components/artist/__tests__/ArtistHero.test.tsx b/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx similarity index 74% rename from renderer/src/components/artist/__tests__/ArtistHero.test.tsx rename to apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx index c55cf4fe..18b9d1b5 100644 --- a/renderer/src/components/artist/__tests__/ArtistHero.test.tsx +++ b/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx @@ -10,7 +10,9 @@ const mockUseImage = vi.fn(); vi.mock('../../../hooks/useImage', () => ({ useImage: (...a: unknown[]) => mockUseImage(...a) })); const mockUseDominantColor = vi.fn(); -vi.mock('../../../hooks/useDominantColor', () => ({ useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a) })); +vi.mock('../../../hooks/useDominantColor', () => ({ + useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a), +})); const QUERY_KEY = ['artist-hero-test']; vi.mock('../../../hooks/useArtistBrowse', () => ({ @@ -34,13 +36,16 @@ vi.mock('../HeroTrackRow', () => ({ onClick: (i: number, e: React.MouseEvent) => void; onAdd: () => void; }) => ( -
onClick(index, e)} - > +
onClick(index, e)}> {track.title} - +
), })); @@ -85,36 +90,32 @@ describe('ArtistHero', () => { // ── basic rendering ────────────────────────────────────────────────────────── it('renders artist name', () => { - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); expect(screen.getByText('The Beatles')).toBeInTheDocument(); }); it('shows 6 placeholder rows when no data is loaded', () => { - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); expect(container.querySelectorAll('[class*="heroTrackPh"]').length).toBe(6); }); it('shows art image when useImage returns a URL', () => { mockUseImage.mockReturnValue('http://img.com/art.jpg'); - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); expect(container.querySelector('img[src="http://img.com/art.jpg"]')).toBeTruthy(); }); it('applies gradient style when dominant color is set', () => { mockUseDominantColor.mockReturnValue('200, 100, 50'); - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); const hero = container.querySelector('[class*="artistHero"]') as HTMLElement; expect(hero?.style.background).toContain('200, 100, 50'); }); @@ -124,10 +125,9 @@ describe('ArtistHero', () => { it('calls onOpen when header is clicked', async () => { const onOpen = vi.fn(); const user = userEvent.setup(); - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); await user.click(screen.getByRole('button')); expect(onOpen).toHaveBeenCalledWith(artistItem); }); @@ -135,10 +135,9 @@ describe('ArtistHero', () => { it('calls onOpen when Enter is pressed on hero header', async () => { const onOpen = vi.fn(); const user = userEvent.setup(); - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); screen.getByRole('button').focus(); await user.keyboard('{Enter}'); expect(onOpen).toHaveBeenCalled(); @@ -148,20 +147,18 @@ describe('ArtistHero', () => { it('renders tracks when data has topSongs', () => { const songs = [makeSong('Come Together', 0), makeSong('Let It Be', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); expect(screen.getByText('Come Together')).toBeInTheDocument(); expect(screen.getByText('Let It Be')).toBeInTheDocument(); }); it('renders at most 6 tracks even when data has more', () => { const songs = Array.from({ length: 8 }, (_, i) => makeSong(`Song ${i + 1}`, i)); - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); expect(screen.queryByText('Song 7')).not.toBeInTheDocument(); expect(screen.getByText('Song 6')).toBeInTheDocument(); }); @@ -169,10 +166,9 @@ describe('ArtistHero', () => { it('calls onAddToQueue when Add button is clicked on a track', () => { const onAddToQueue = vi.fn(); const songs = [makeSong('Come Together', 0)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByText('Add')); expect(onAddToQueue).toHaveBeenCalledWith(songs[0].raw); }); @@ -181,10 +177,9 @@ describe('ArtistHero', () => { it('single-clicking a track selects it', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); expect(screen.getByTestId('track-1')).toHaveAttribute('data-selected', 'false'); @@ -192,10 +187,9 @@ describe('ArtistHero', () => { it('clicking a selected track deselects it', () => { const songs = [makeSong('Song A', 0)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); fireEvent.click(screen.getByTestId('track-0')); @@ -204,10 +198,9 @@ describe('ArtistHero', () => { it('ctrl+click toggles an additional track into selection', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); fireEvent.click(screen.getByTestId('track-1'), { ctrlKey: true }); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); @@ -216,10 +209,9 @@ describe('ArtistHero', () => { it('ctrl+click on a selected track deselects it', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); fireEvent.click(screen.getByTestId('track-1'), { ctrlKey: true }); fireEvent.click(screen.getByTestId('track-0'), { ctrlKey: true }); @@ -229,10 +221,9 @@ describe('ArtistHero', () => { it('shift+click range-selects from last clicked to target', () => { const songs = Array.from({ length: 5 }, (_, i) => makeSong(`Song ${i}`, i)); - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-1')); fireEvent.click(screen.getByTestId('track-3'), { shiftKey: true }); expect(screen.getByTestId('track-1')).toHaveAttribute('data-selected', 'true'); @@ -244,10 +235,9 @@ describe('ArtistHero', () => { it('clicking the outer div clears selection', () => { const songs = [makeSong('Song A', 0)]; - const { container } = render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); // Click the outer hero div (not the track or header) diff --git a/renderer/src/components/artist/__tests__/ArtistPanel.test.tsx b/apps/desktop/renderer/src/components/artist/__tests__/ArtistPanel.test.tsx similarity index 100% rename from renderer/src/components/artist/__tests__/ArtistPanel.test.tsx rename to apps/desktop/renderer/src/components/artist/__tests__/ArtistPanel.test.tsx diff --git a/renderer/src/components/artist/__tests__/smallComponents.test.tsx b/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx similarity index 77% rename from renderer/src/components/artist/__tests__/smallComponents.test.tsx rename to apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx index 5c537912..61de7656 100644 --- a/renderer/src/components/artist/__tests__/smallComponents.test.tsx +++ b/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx @@ -64,30 +64,75 @@ function makeAlbumTrack(overrides: Partial = {}): AlbumTrack { describe('TopSongRow', () => { it('renders track title', () => { - render(); + render( + + ); expect(screen.getByText('Come Together')).toBeInTheDocument(); }); it('renders 1-based index number', () => { - render(); + render( + + ); expect(screen.getByText('3')).toBeInTheDocument(); }); it('renders formatted duration', () => { - render(); + render( + + ); expect(screen.getByText('4:19')).toBeInTheDocument(); }); it('calls onAdd with raw item when + is clicked', () => { const onAdd = vi.fn(); const raw = { name: 'CT', type: 'TRACK' } as SonosItem; - render(); + render( + + ); fireEvent.click(screen.getByText('+')); expect(onAdd).toHaveBeenCalledWith(raw); }); it('shows ExplicitBadge when explicit is true', () => { - render(); + render( + + ); expect(screen.getByTitle('Explicit')).toBeInTheDocument(); }); }); @@ -169,7 +214,14 @@ function makeHeroTrack(overrides: Partial = {}): HeroTrack { describe('HeroTrackRow', () => { it('renders track title', () => { render( - + ); expect(screen.getByText('Hey Jude')).toBeInTheDocument(); }); @@ -177,7 +229,14 @@ describe('HeroTrackRow', () => { it('calls onClick when row is clicked', () => { const onClick = vi.fn(); render( - + ); fireEvent.click(screen.getByText('Hey Jude')); expect(onClick).toHaveBeenCalledWith(3, expect.any(Object)); @@ -186,7 +245,14 @@ describe('HeroTrackRow', () => { it('calls onAdd when + button is clicked', () => { const onAdd = vi.fn(); render( - + ); fireEvent.click(screen.getByText('+')); expect(onAdd).toHaveBeenCalled(); @@ -194,14 +260,28 @@ describe('HeroTrackRow', () => { it('renders formatted duration', () => { render( - + ); expect(screen.getByText('7:11')).toBeInTheDocument(); }); it('shows ExplicitBadge when explicit is true', () => { render( - + ); expect(screen.getByTitle('Explicit')).toBeInTheDocument(); }); diff --git a/renderer/src/components/common/ContextMenu.tsx b/apps/desktop/renderer/src/components/common/ContextMenu.tsx similarity index 69% rename from renderer/src/components/common/ContextMenu.tsx rename to apps/desktop/renderer/src/components/common/ContextMenu.tsx index 2d693d18..ba25aa60 100644 --- a/renderer/src/components/common/ContextMenu.tsx +++ b/apps/desktop/renderer/src/components/common/ContextMenu.tsx @@ -81,16 +81,9 @@ function PlaylistSubmenu({ const top = clamp(anchor.y, 8, window.innerHeight - 8); return createPortal( -
e.stopPropagation()} - > +
e.stopPropagation()}> {isLoading &&
Loading…
} - {!isLoading && allPlaylists.length === 0 && ( -
No playlists yet
- )} + {!isLoading && allPlaylists.length === 0 &&
No playlists yet
} {allPlaylists.map((pl) => (
handleAdd(pl)}> ♪ @@ -101,13 +94,16 @@ function PlaylistSubmenu({ {allPlaylists.length > 0 &&
}
{ onClose(); onOpenCreate(); }} + onClick={() => { + onClose(); + onOpenCreate(); + }} > + Create new playlist…
, - document.body, + document.body ); } @@ -116,7 +112,12 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro const queryClient = useQueryClient(); const { showToast } = useToast(); const [menuState, setMenuState] = useState({ - open: false, x: 0, y: 0, payload: null, submenuOpen: false, submenuAnchor: null, + open: false, + x: 0, + y: 0, + payload: null, + submenuOpen: false, + submenuAnchor: null, }); const [createOpen, setCreateOpen] = useState(false); const [pendingTrack, setPendingTrack] = useState(null); @@ -205,49 +206,51 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro return ( {children} - {menuState.open && menuState.payload && createPortal( -
e.stopPropagation()} - > - {menuState.payload.sonosItem && ( - <> -
- ▶ - Play next -
-
- + - Add to queue + {menuState.open && + menuState.payload && + createPortal( +
e.stopPropagation()} + > + {menuState.payload.sonosItem && ( + <> +
+ ▶ + Play next +
+
+ + + Add to queue +
+
+ + )} + {displayName && ( +
+ ❤️ + Add to Favourites
-
- - )} - {displayName && ( -
- ❤️ - Add to Favourites + )} +
+ ♪ + Add to playlist + ▶
- )} -
- ♪ - Add to playlist - ▶ -
- {menuState.payload.track.addedBy && ( - <> -
-
- 👤 - View {menuState.payload.track.addedBy}'s profile -
- - )} -
, - document.body, - )} + {menuState.payload.track.addedBy && ( + <> +
+
+ 👤 + View {menuState.payload.track.addedBy}'s profile +
+ + )} +
, + document.body + )} {menuState.open && menuState.submenuOpen && menuState.submenuAnchor && menuState.payload && ( { setCreateOpen(false); setPendingTrack(null); }} + onClose={() => { + setCreateOpen(false); + setPendingTrack(null); + }} /> )} @@ -313,17 +319,27 @@ export function CreatePlaylistDialog({ return createPortal(
e.stopPropagation()} > @@ -332,15 +348,32 @@ export function CreatePlaylistDialog({ ref={inputRef} value={name} onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') onClose(); }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') onClose(); + }} placeholder="Playlist name" style={{ - background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, - padding: '8px 12px', color: 'var(--text)', fontSize: 14, outline: 'none', + background: 'var(--bg-2)', + border: '1px solid var(--border)', + borderRadius: 8, + padding: '8px 12px', + color: 'var(--text)', + fontSize: 14, + outline: 'none', fontFamily: 'inherit', }} /> -
, - document.body, + document.body ); } diff --git a/renderer/src/components/common/ExplicitBadge.module.css b/apps/desktop/renderer/src/components/common/ExplicitBadge.module.css similarity index 100% rename from renderer/src/components/common/ExplicitBadge.module.css rename to apps/desktop/renderer/src/components/common/ExplicitBadge.module.css diff --git a/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx b/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx new file mode 100644 index 00000000..56118780 --- /dev/null +++ b/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx @@ -0,0 +1,9 @@ +import styles from './ExplicitBadge.module.css'; + +export function ExplicitBadge() { + return ( + + E + + ); +} diff --git a/renderer/src/components/common/MediaCard.module.css b/apps/desktop/renderer/src/components/common/MediaCard.module.css similarity index 100% rename from renderer/src/components/common/MediaCard.module.css rename to apps/desktop/renderer/src/components/common/MediaCard.module.css diff --git a/renderer/src/components/common/MediaCard.tsx b/apps/desktop/renderer/src/components/common/MediaCard.tsx similarity index 64% rename from renderer/src/components/common/MediaCard.tsx rename to apps/desktop/renderer/src/components/common/MediaCard.tsx index de4b7c61..ae327ac6 100644 --- a/renderer/src/components/common/MediaCard.tsx +++ b/apps/desktop/renderer/src/components/common/MediaCard.tsx @@ -17,18 +17,26 @@ export function MediaCard({ name, sub, artUrl, explicit, circular, onAdd, onOpen return (
- {cachedArt - ? - :
♪
- } + {cachedArt ? :
♪
} {onAdd && (
- +
)}
-
{name}{explicit && }
+
+ {name} + {explicit && } +
{sub &&
{sub}
}
diff --git a/apps/desktop/renderer/src/components/common/MediaRow.module.css b/apps/desktop/renderer/src/components/common/MediaRow.module.css new file mode 100644 index 00000000..81b62e16 --- /dev/null +++ b/apps/desktop/renderer/src/components/common/MediaRow.module.css @@ -0,0 +1,139 @@ +.row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 8px; + border-radius: var(--r-sm); + transition: background 0.12s; + cursor: default; +} +.row:hover { + background: var(--bg-2); +} +.clickable { + cursor: pointer; +} +.draggable { + cursor: grab; +} +.draggable:active { + cursor: grabbing; +} + +.selected { + background: rgba(255, 255, 255, 0.08); +} +.selected:hover { + background: rgba(255, 255, 255, 0.12) !important; +} + +.leading { + flex-shrink: 0; +} + +.artWrap { + position: relative; + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.art { + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + background: var(--bg-1); + display: block; +} + +.artPh { + width: 40px; + height: 40px; + border-radius: 4px; + background: var(--bg-1); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-3); + font-size: 14px; +} + +.overlay { + position: absolute; + inset: 0; + border-radius: 4px; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + color: var(--text); + font-size: 10px; + pointer-events: none; +} +.playing .overlay { + display: flex; +} + +.text { + flex: 1; + overflow: hidden; + min-width: 0; +} + +.name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); + display: flex; + align-items: center; + gap: 4px; +} +.playing .name { + color: #fff; + font-weight: 600; +} + +.sub { + font-size: 11px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.trailing { + flex-shrink: 0; + font-size: 11px; + color: var(--text-3); +} + +.addBtn { + width: 26px; + height: 26px; + background: none; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-3); + cursor: pointer; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; + opacity: 0; +} +.row:hover .addBtn { + opacity: 1; +} +.addBtn:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); +} diff --git a/renderer/src/components/common/MediaRow.tsx b/apps/desktop/renderer/src/components/common/MediaRow.tsx similarity index 64% rename from renderer/src/components/common/MediaRow.tsx rename to apps/desktop/renderer/src/components/common/MediaRow.tsx index 1c74724f..5cd3b45e 100644 --- a/renderer/src/components/common/MediaRow.tsx +++ b/apps/desktop/renderer/src/components/common/MediaRow.tsx @@ -21,16 +21,31 @@ interface Props { } export function MediaRow({ - name, artUrl, explicit, isPlaying, isSelected, leading, subtitle, trailing, - onAdd, onClick, onDoubleClick, onHover, onContextMenu, draggable, onDragStart, + name, + artUrl, + explicit, + isPlaying, + isSelected, + leading, + subtitle, + trailing, + onAdd, + onClick, + onDoubleClick, + onHover, + onContextMenu, + draggable, + onDragStart, }: Props) { const cachedArt = useImage(artUrl); const classes = [ styles.row, - isPlaying ? styles.playing : '', - isSelected ? styles.selected : '', - draggable ? styles.draggable : (onClick ? styles.clickable : ''), - ].filter(Boolean).join(' '); + isPlaying ? styles.playing : '', + isSelected ? styles.selected : '', + draggable ? styles.draggable : onClick ? styles.clickable : '', + ] + .filter(Boolean) + .join(' '); return (
{leading !== undefined &&
{leading}
}
- {cachedArt - ? - :
♪
- } + {cachedArt ? :
♪
}
▶
-
{name}{explicit && }
+
+ {name} + {explicit && } +
{subtitle !== undefined &&
{subtitle}
}
{trailing !== undefined &&
{trailing}
} {onAdd && ( - + )}
); diff --git a/renderer/src/components/common/PlaylistCard.module.css b/apps/desktop/renderer/src/components/common/PlaylistCard.module.css similarity index 93% rename from renderer/src/components/common/PlaylistCard.module.css rename to apps/desktop/renderer/src/components/common/PlaylistCard.module.css index b6342c28..a7d4c802 100644 --- a/renderer/src/components/common/PlaylistCard.module.css +++ b/apps/desktop/renderer/src/components/common/PlaylistCard.module.css @@ -10,7 +10,9 @@ width: 120px; text-align: left; } -.playlistCard:hover .playlistCardArt { opacity: 0.85; } +.playlistCard:hover .playlistCardArt { + opacity: 0.85; +} .playlistCardArt { width: 120px; diff --git a/renderer/src/components/common/PlaylistCard.tsx b/apps/desktop/renderer/src/components/common/PlaylistCard.tsx similarity index 61% rename from renderer/src/components/common/PlaylistCard.tsx rename to apps/desktop/renderer/src/components/common/PlaylistCard.tsx index f612fdea..06de4f0b 100644 --- a/renderer/src/components/common/PlaylistCard.tsx +++ b/apps/desktop/renderer/src/components/common/PlaylistCard.tsx @@ -14,12 +14,27 @@ export function PlaylistCard({ pl, displayName, onClick }: Props) { } - /> - ); + render(Artist Name} />); expect(screen.getByRole('button', { name: 'Artist Name' })).toBeInTheDocument(); }); diff --git a/renderer/src/components/queue/DjUpcomingRow.tsx b/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx similarity index 90% rename from renderer/src/components/queue/DjUpcomingRow.tsx rename to apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx index 2c4f67ce..94d4106e 100644 --- a/renderer/src/components/queue/DjUpcomingRow.tsx +++ b/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx @@ -30,11 +30,7 @@ export function DjUpcomingRow({ track, idle }: Props) { rows above without implying these can be dragged. */}