Web Player Refactor #2
@@ -0,0 +1,116 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: production-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: Backend tests (.NET)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore Musify.slnx
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build Musify.slnx --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test Musify.slnx --no-build -c Release
|
||||||
|
|
||||||
|
web-player-checks:
|
||||||
|
name: web-player (check + lint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web-player
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web-player/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Type-check (svelte-check)
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Lint (prettier + eslint + design tokens)
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# Runs on the production host itself, through the existing self-hosted
|
||||||
|
# Gitea Actions runner (act_runner), which also carries a "production"
|
||||||
|
# label registered in native (non-docker) mode so its steps see the host's
|
||||||
|
# real Docker daemon directly (Docker socket + a bind-mounted deploy
|
||||||
|
# directory — see deploy/README.md for how that runner is set up).
|
||||||
|
#
|
||||||
|
# DEPLOY_PATH is the persistent clone already on that host (the one whose
|
||||||
|
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
|
||||||
|
# deploy reuses it instead of a fresh checkout, since .env.prod is
|
||||||
|
# untracked and only lives there.
|
||||||
|
deploy:
|
||||||
|
name: Deploy to production
|
||||||
|
needs: [backend-tests, web-player-checks]
|
||||||
|
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||||
|
runs-on: [production]
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /home/ubuntu/deploys/musify
|
||||||
|
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
|
||||||
|
steps:
|
||||||
|
- name: Pull latest master
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
|
||||||
|
- name: Migrate database, generate keys
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE up -d wait-for-postgres keys-init migrate
|
||||||
|
|
||||||
|
# zitadel-init/seaweedfs-init verify themselves through the PUBLIC
|
||||||
|
# auth./s3. URLs (see deploy/compose.yml), so they only succeed once a
|
||||||
|
# real domain is registered and nginx is serving it (see the TODO
|
||||||
|
# below and deploy/README.md). Tolerated for now so a red step here
|
||||||
|
# doesn't block the rest of the deploy; drop the `|| true` once that's
|
||||||
|
# in place, so a real regression here fails the job again.
|
||||||
|
- name: Create OIDC app + S3 bucket (pending public domain)
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE up -d seaweedfs-init zitadel-init || echo "expected to fail until the production domain + nginx edge exist"
|
||||||
|
|
||||||
|
- name: Build and start apps
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE --profile apps up -d --build api worker gateway web-player
|
||||||
|
|
||||||
|
# TODO once a domain is registered and pointed at this server: merge
|
||||||
|
# Musify's vhosts (deploy/nginx/templates) into the existing
|
||||||
|
# gitea-nginx config instead of running the `nginx` service from
|
||||||
|
# compose.prod.yml, since gitea-nginx already owns host ports 80/443
|
||||||
|
# for gitea.ikerdemo.cat. See "Continuous deployment" in
|
||||||
|
# deploy/README.md.
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production configuration for deploy/compose.yml + compose.prod.yml.
|
# Production configuration for deploy/compose.yml + compose.prod.yml.
|
||||||
# Copy to deploy/.env.prod, then:
|
# Copy to deploy/.env.prod, then:
|
||||||
#
|
#
|
||||||
# 1. replace every `example.com` with your domain,
|
# 1. replace every `*.example.com` with your actual domains (each service
|
||||||
|
# below has its own, independent domain — they don't need to share a
|
||||||
|
# root, e.g. for DNS providers that don't allow multi-level
|
||||||
|
# subdomains),
|
||||||
# 2. replace every CHANGE_ME with a strong random value
|
# 2. replace every CHANGE_ME with a strong random value
|
||||||
# (`openssl rand -hex 24`),
|
# (`openssl rand -hex 24`),
|
||||||
# 3. point these DNS records at the server, all proxied/TLS-terminated the
|
# 3. point each *_DOMAIN below at the server, proxied/TLS-terminated the
|
||||||
# same way: example.com, www, api, auth, stream, s3.
|
# same way.
|
||||||
#
|
#
|
||||||
# Requires ../../../Infrastructure running first, on the same Docker host,
|
# Requires ../../../Infrastructure running first, on the same Docker host,
|
||||||
# with its own `infra-net` network already created. See
|
# with its own `infra-net` network already created. See
|
||||||
@@ -14,12 +17,18 @@
|
|||||||
|
|
||||||
DOTNET_ENVIRONMENT=Production
|
DOTNET_ENVIRONMENT=Production
|
||||||
|
|
||||||
# --- Domain ------------------------------------------------------------------
|
# --- Domains -------------------------------------------------------------
|
||||||
# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
|
# Each *_DOMAIN drives its own nginx vhost (deploy/nginx/templates); the
|
||||||
ROOT_DOMAIN=example.com
|
# PUBLIC_*_URL/S3_PUBLIC_URL below must agree with it (https://<domain>).
|
||||||
PUBLIC_WEB_URL=https://example.com
|
WEB_DOMAIN=musify.example.com
|
||||||
PUBLIC_API_URL=https://api.example.com
|
API_DOMAIN=musifyapi.example.com
|
||||||
PUBLIC_STREAM_URL=https://stream.example.com
|
STREAM_DOMAIN=musifystream.example.com
|
||||||
|
AUTH_DOMAIN=auth.example.com
|
||||||
|
S3_DOMAIN=s3.example.com
|
||||||
|
|
||||||
|
PUBLIC_WEB_URL=https://musify.example.com
|
||||||
|
PUBLIC_API_URL=https://musifyapi.example.com
|
||||||
|
PUBLIC_STREAM_URL=https://musifystream.example.com
|
||||||
PUBLIC_AUTH_URL=https://auth.example.com
|
PUBLIC_AUTH_URL=https://auth.example.com
|
||||||
S3_PUBLIC_URL=https://s3.example.com
|
S3_PUBLIC_URL=https://s3.example.com
|
||||||
|
|
||||||
|
|||||||
+63
-4
@@ -125,14 +125,19 @@ cp deploy/.env.prod.example deploy/.env.prod
|
|||||||
|
|
||||||
Fill it in:
|
Fill it in:
|
||||||
|
|
||||||
1. Replace every `example.com` with your domain and every `CHANGE_ME` with a
|
1. Replace every `*.example.com` with your actual domains and every
|
||||||
random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
|
`CHANGE_ME` with a random value (`openssl rand -hex 24`). Each of
|
||||||
|
`WEB_DOMAIN`/`API_DOMAIN`/`STREAM_DOMAIN`/`AUTH_DOMAIN`/`S3_DOMAIN` is
|
||||||
|
independent — they don't need to share a root domain (e.g.
|
||||||
|
`musify.example.com` + `musifyapi.example.com`, or sibling subdomains of
|
||||||
|
a domain whose DNS provider doesn't allow multi-level subdomains).
|
||||||
|
`chmod 600 deploy/.env.prod`.
|
||||||
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin
|
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin
|
||||||
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
|
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
|
||||||
mode in front of it, so a Cloudflare Origin Certificate is enough; any
|
mode in front of it, so a Cloudflare Origin Certificate is enough; any
|
||||||
certificate valid for the five hostnames works.
|
certificate valid for the five hostnames works.
|
||||||
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
|
3. Point the five `*_DOMAIN` values at the server, proxied and
|
||||||
proxied and TLS-terminated the same way.
|
TLS-terminated the same way.
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
@@ -150,6 +155,60 @@ an update once the stack already exists).
|
|||||||
Only nginx publishes ports (80/443). Every other Musify service, and all of
|
Only nginx publishes ports (80/443). Every other Musify service, and all of
|
||||||
Infrastructure's, is reachable only on the internal `infra-net` network.
|
Infrastructure's, is reachable only on the internal `infra-net` network.
|
||||||
|
|
||||||
|
### Continuous deployment
|
||||||
|
|
||||||
|
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every
|
||||||
|
push/PR, and on a successful push to `master` deploys automatically. The
|
||||||
|
deploy job runs on `gitea-runner`, the existing self-hosted Gitea Actions
|
||||||
|
runner on the production host itself (`gitea.ikerdemo.cat`, in
|
||||||
|
`~/gitea/docker-compose.yml`), which also carries a `production:host`
|
||||||
|
label registered directly in `~/gitea/runner-data/.runner` (act_runner
|
||||||
|
reads `labels` from that file on every start, not from
|
||||||
|
`GITEA_RUNNER_LABELS`, once a runner is already registered). Running in
|
||||||
|
native mode means the label's steps execute inside the runner container
|
||||||
|
itself rather than spinning up a fresh one — that container has
|
||||||
|
`/var/run/docker.sock` mounted, so `docker`/`docker compose` there still
|
||||||
|
control the host's real Docker daemon (sibling containers, "Docker outside
|
||||||
|
of Docker"). Its compose service also bind-mounts `/home/ubuntu/deploys`
|
||||||
|
into itself at the *same* path, which matters because compose's own
|
||||||
|
relative bind mounts (`./keys`, `./nginx/...`) are resolved by the host
|
||||||
|
daemon against that literal path — mounting it anywhere else would silently
|
||||||
|
bind the wrong (empty) directory.
|
||||||
|
|
||||||
|
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`)
|
||||||
|
is a persistent clone on that host, reused instead of a fresh checkout each
|
||||||
|
run since `deploy/.env.prod` is untracked and only lives there.
|
||||||
|
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the
|
||||||
|
`Infrastructure`/`SharedServices` repo, already running (`infra-net`
|
||||||
|
created); its `.env.prod` shares the Postgres/RabbitMQ/S3 credentials with
|
||||||
|
Musify's own `deploy/.env.prod` by design (see that repo's README).
|
||||||
|
|
||||||
|
**Pending DNS on the registered domains:** `deploy/.env.prod`'s five
|
||||||
|
`*_DOMAIN` values are set (`musify.ikerdemo.cat`, `musifyapi.ikerdemo.cat`,
|
||||||
|
`musifystream.ikerdemo.cat`, `auth.ikerdemo.cat`, `s3.ikerdemo.cat` —
|
||||||
|
siblings, not `api.musify.ikerdemo.cat`, because the DNS provider here
|
||||||
|
doesn't allow multi-level subdomains), but no DNS record points any of them
|
||||||
|
at the server yet, so:
|
||||||
|
|
||||||
|
- `zitadel-init` and `seaweedfs-init` (they verify themselves through the
|
||||||
|
public `auth.`/`s3.` URLs) fail every deploy until those two resolve and
|
||||||
|
are actually reachable — the workflow tolerates this on purpose, see its
|
||||||
|
comments. Until then, `api`/`worker` build and start but can't do
|
||||||
|
anything OIDC- or storage-dependent (`AUTH_CLIENT_ID`/`WEB_CLIENT_ID`/
|
||||||
|
`WEB_CLIENT_SECRET` in `.env.prod` stay blank); `gateway`/`web-player`
|
||||||
|
are unaffected.
|
||||||
|
- The `nginx` service from `compose.prod.yml` is intentionally **not**
|
||||||
|
brought up by the deploy job: it would try to publish host ports 80/443,
|
||||||
|
which `gitea-nginx` already owns for `gitea.ikerdemo.cat` (certbot). Once
|
||||||
|
the five DNS records above exist, merge Musify's vhosts
|
||||||
|
(`deploy/nginx/templates`) into `~/gitea/nginx/conf.d` instead — same
|
||||||
|
nginx, additional `server_name` blocks proxying to Musify's containers
|
||||||
|
over `infra-net` — rather than running a second nginx that would conflict
|
||||||
|
on those ports. That also needs a certificate valid for the 5 hostnames
|
||||||
|
(the existing setup uses certbot/Let's Encrypt, not the Cloudflare Origin
|
||||||
|
cert `compose.prod.yml`'s nginx assumes — reconcile whichever way when
|
||||||
|
that's set up).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# The entrypoint renders /etc/nginx/templates/*.template with envsubst.
|
# The entrypoint renders /etc/nginx/templates/*.template with envsubst.
|
||||||
# The filter keeps it from eating nginx's own $host / $remote_addr.
|
# The filter keeps it from eating nginx's own $host / $remote_addr.
|
||||||
MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN}
|
MUSIFY_WEB_DOMAIN: ${WEB_DOMAIN}
|
||||||
|
MUSIFY_API_DOMAIN: ${API_DOMAIN}
|
||||||
|
MUSIFY_STREAM_DOMAIN: ${STREAM_DOMAIN}
|
||||||
|
MUSIFY_AUTH_DOMAIN: ${AUTH_DOMAIN}
|
||||||
|
MUSIFY_S3_DOMAIN: ${S3_DOMAIN}
|
||||||
MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m}
|
MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m}
|
||||||
NGINX_ENVSUBST_FILTER: ^MUSIFY_
|
NGINX_ENVSUBST_FILTER: ^MUSIFY_
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Musify edge. The nginx entrypoint renders this with envsubst into
|
# Musify edge. The nginx entrypoint renders this with envsubst into
|
||||||
# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the
|
# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the
|
||||||
# MUSIFY_* variables set in compose.prod.yml.
|
# MUSIFY_* variables set in compose.prod.yml. Each vhost has its own,
|
||||||
|
# independent domain (see deploy/.env.prod.example) — they don't need to
|
||||||
|
# share a root domain.
|
||||||
#
|
#
|
||||||
# Cloudflare (Full strict) terminates the public edge; this box only needs to
|
# Cloudflare (Full strict) terminates the public edge; this box only needs to
|
||||||
# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to
|
# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to
|
||||||
@@ -17,9 +19,8 @@ map $http_upgrade $connection_upgrade {
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN}
|
server_name ${MUSIFY_WEB_DOMAIN} ${MUSIFY_API_DOMAIN}
|
||||||
api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN}
|
${MUSIFY_AUTH_DOMAIN} ${MUSIFY_STREAM_DOMAIN} ${MUSIFY_S3_DOMAIN};
|
||||||
stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN};
|
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name ${MUSIFY_ROOT_DOMAIN};
|
server_name ${MUSIFY_WEB_DOMAIN};
|
||||||
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
@@ -44,18 +45,7 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name www.${MUSIFY_ROOT_DOMAIN};
|
server_name ${MUSIFY_API_DOMAIN};
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
|
||||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
|
||||||
|
|
||||||
return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name api.${MUSIFY_ROOT_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
@@ -71,7 +61,7 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name stream.${MUSIFY_ROOT_DOMAIN};
|
server_name ${MUSIFY_STREAM_DOMAIN};
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
@@ -87,7 +77,7 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name auth.${MUSIFY_ROOT_DOMAIN};
|
server_name ${MUSIFY_AUTH_DOMAIN};
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
@@ -104,7 +94,7 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name s3.${MUSIFY_ROOT_DOMAIN};
|
server_name ${MUSIFY_S3_DOMAIN};
|
||||||
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
|
|||||||
## Home (app) `/` 🟡
|
## Home (app) `/` 🟡
|
||||||
|
|
||||||
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
|
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
|
||||||
- **Quick access, meaning recently played** (`player.recentlyPlayed`, client side for now). Empty state with an icon and an "Explore" CTA.
|
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA.
|
||||||
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
|
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
|
||||||
|
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session).
|
||||||
|
|
||||||
### Pending backend work (ASP.NET) for Home
|
### Pending backend work (ASP.NET) for Home
|
||||||
|
|
||||||
- **Recently played**: track plays per user (a history table with `userId`, `trackId`, `playedAt`) and expose `GET /me/recently-played?limit=` (or `/users/{id}/recently-played`), returning tracks ordered by `playedAt` descending, with no duplicates. The frontend would load this in a `load` function and pass it to the store instead of using `recentlyPlayed`.
|
|
||||||
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
|
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
|
||||||
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
|
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
|
||||||
|
|
||||||
|
|||||||
Generated
+55
-14
@@ -39,6 +39,40 @@
|
|||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.3",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
@@ -1118,7 +1152,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||||
@@ -1170,7 +1203,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -1408,6 +1440,27 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1544,7 +1597,6 @@
|
|||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1606,7 +1658,6 @@
|
|||||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.64.0",
|
"@typescript-eslint/scope-manager": "8.64.0",
|
||||||
"@typescript-eslint/types": "8.64.0",
|
"@typescript-eslint/types": "8.64.0",
|
||||||
@@ -1714,7 +1765,6 @@
|
|||||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1798,7 +1848,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2033,7 +2082,6 @@
|
|||||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -3112,7 +3160,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3246,7 +3293,6 @@
|
|||||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3263,7 +3309,6 @@
|
|||||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -3434,7 +3479,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -3569,7 +3613,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3781,7 +3824,6 @@
|
|||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3843,7 +3885,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|||||||
Vendored
+46
@@ -553,6 +553,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/users/{id}/last-listened-track": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
get: operations["GetLastTrackListenedByUserId"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/users/{id}/listening-stats": {
|
"/users/{id}/listening-stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2375,6 +2392,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
GetLastTrackListenedByUserId: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: number | string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TrackApplicationResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
GetUserProfile: {
|
GetUserProfile: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -315,6 +315,15 @@ class PlayerState {
|
|||||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hydrate(item: ApiTrackLike) {
|
||||||
|
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||||
|
const track = toTrack(toQueueItem(item));
|
||||||
|
this.tracks = [track];
|
||||||
|
this.currentId = track.id;
|
||||||
|
this.#applyAccent(track.id);
|
||||||
|
this.#syncMediaSessionMetadata(track);
|
||||||
|
}
|
||||||
|
|
||||||
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
|
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
|
||||||
return ids
|
return ids
|
||||||
.map((id) => this.tracks.find((t) => t.id === id))
|
.map((id) => this.tracks.find((t) => t.id === id))
|
||||||
@@ -384,6 +393,10 @@ class PlayerState {
|
|||||||
toggle() {
|
toggle() {
|
||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
if (!audio || this.currentId === null) return;
|
if (!audio || this.currentId === null) return;
|
||||||
|
if (!audio.src) {
|
||||||
|
this.#loadCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (audio.paused) audio.play().catch(() => {});
|
if (audio.paused) audio.play().catch(() => {});
|
||||||
else audio.pause();
|
else audio.pause();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchLastPlayedTrack(
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
accessToken: string | null,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||||
|
const { data } = await api.GET('/users/{id}/last-listened-track', {
|
||||||
|
params: { path: { id: userId } }
|
||||||
|
});
|
||||||
|
return data ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||||
|
|
||||||
@@ -56,13 +72,15 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
allowAnonymousListening,
|
allowAnonymousListening,
|
||||||
sidebarPlaylists: [],
|
sidebarPlaylists: [],
|
||||||
likedTracks: [],
|
likedTracks: [],
|
||||||
|
lastPlayedTrack: null,
|
||||||
accountUrl
|
accountUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sidebarPlaylists, likedTracks] = await Promise.all([
|
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
|
||||||
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
|
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
|
||||||
fetchLikedTracks(fetch, locals.accessToken)
|
fetchLikedTracks(fetch, locals.accessToken),
|
||||||
|
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -70,6 +88,7 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
allowAnonymousListening,
|
allowAnonymousListening,
|
||||||
sidebarPlaylists,
|
sidebarPlaylists,
|
||||||
likedTracks,
|
likedTracks,
|
||||||
|
lastPlayedTrack,
|
||||||
accountUrl
|
accountUrl
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,15 +30,19 @@
|
|||||||
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
|
||||||
|
});
|
||||||
|
|
||||||
const isAuthPage = $derived(page.url.pathname === '/auth');
|
const isAuthPage = $derived(page.url.pathname === '/auth');
|
||||||
const hasTrack = $derived(player.currentId !== null);
|
const hasTrack = $derived(player.currentId !== null);
|
||||||
|
|
||||||
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
||||||
|
|
||||||
let hadTrack = false;
|
let wasPlaying = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (hasTrack && !hadTrack) queuePanel.show();
|
if (player.playing && !wasPlaying) queuePanel.show();
|
||||||
hadTrack = hasTrack;
|
wasPlaying = player.playing;
|
||||||
});
|
});
|
||||||
|
|
||||||
function isTypingTarget(target: EventTarget | null): boolean {
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user