Compare commits
166
Commits
569af968c5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbeca268c | ||
|
|
9afd73ae7f | ||
|
|
7cdcc352d6 | ||
|
|
624832959d | ||
|
|
b6e98463ee | ||
|
|
ae357b0eb9 | ||
|
|
17a677a5a5 | ||
|
|
1366ec3832 | ||
|
|
7879e37a7b | ||
|
|
2f87a12f08 | ||
|
|
748b1446a2 | ||
|
|
06b13caea9 | ||
|
|
1432a960fb | ||
|
|
37f8f8acac | ||
|
|
c7fed82736 | ||
|
|
e8582381ec | ||
|
|
899c98977a | ||
|
|
549bec85ce | ||
|
|
2258e5465f | ||
|
|
2641a6df44 | ||
|
|
d5b5c8cf84 | ||
|
|
77a3bb33f0 | ||
|
|
d420123a44 | ||
|
|
f083fad986 | ||
|
|
363eb2b1aa | ||
|
|
1aa218f3e3 | ||
|
|
6652fb4d2b | ||
|
|
073593d963 | ||
|
|
8d25bf0404 | ||
|
|
24558497da | ||
|
|
eb72e5c27d | ||
|
|
66fcdbba05 | ||
|
|
86810b6a51 | ||
|
|
cb43c3375e | ||
|
|
ea7167f851 | ||
|
|
f3d54158d3 | ||
|
|
908488a337 | ||
|
|
19f241e493 | ||
|
|
5f043beb5d | ||
|
|
de674dbe3f | ||
|
|
ffedc7532b | ||
|
|
e2e7b2490f | ||
|
|
8bc03da88e | ||
|
|
35e36067af | ||
|
|
cb5ea26c77 | ||
|
|
568bb5002c | ||
|
|
ea448e109f | ||
|
|
4ee20de359 | ||
|
|
ac69d259d5 | ||
|
|
ec75fdef7b | ||
|
|
fca7cc79c1 | ||
|
|
651f979401 | ||
|
|
0b23a07039 | ||
|
|
8da67e0975 | ||
|
|
e8108b0a7a | ||
|
|
871ecdd72e | ||
|
|
a2211a62d2 | ||
|
|
65fa950043 | ||
|
|
92c3d96936 | ||
|
|
dfae34c706 | ||
|
|
334aa51a09 | ||
|
|
83570b22b5 | ||
|
|
dea0485a54 | ||
|
|
4323d68edc | ||
|
|
91d0d5fd0b | ||
|
|
5a6cff8105 | ||
|
|
5f1650b82a | ||
|
|
94fdc3274e | ||
|
|
a89e2974bc | ||
|
|
430ecdc737 | ||
|
|
3c99778567 | ||
|
|
f62b248bb6 | ||
|
|
aae358c4c7 | ||
|
|
a05c15df93 | ||
|
|
da32d2911a | ||
|
|
39d47b78d4 | ||
|
|
eee98d193f | ||
|
|
33b3df609a | ||
|
|
6d8a8a1d5e | ||
|
|
02b590505a | ||
|
|
9f0d4c3153 | ||
|
|
e91708bc97 | ||
|
|
0029b89de4 | ||
|
|
3e8897b551 | ||
|
|
043ec3a108 | ||
|
|
62391e4aae | ||
|
|
767d088665 | ||
|
|
1d8d8143f2 | ||
|
|
149443e4f7 | ||
|
|
93ee958434 | ||
|
|
a780f6622b | ||
|
|
6da40d9e63 | ||
|
|
2fd4d978d0 | ||
|
|
845f125d76 | ||
|
|
3ceba8a365 | ||
|
|
3000dc1622 | ||
|
|
33d24c6ed4 | ||
|
|
908b50bcd9 | ||
|
|
7996971ba0 | ||
|
|
026b392657 | ||
|
|
ed641e999c | ||
|
|
67642f8ab8 | ||
|
|
b7c96451d8 | ||
|
|
d11de8a69e | ||
|
|
b584159568 | ||
|
|
f56d47800c | ||
|
|
2208ce77b6 | ||
|
|
f27eaf9c82 | ||
|
|
7b4599c50a | ||
|
|
934253a036 | ||
|
|
0a6c81b4a1 | ||
|
|
593c9fdb7b | ||
|
|
cb7a3d77be | ||
|
|
635ba8d353 | ||
|
|
31d4a72e62 | ||
|
|
4daf87b550 | ||
|
|
8fc6a3aaef | ||
|
|
ef019b9b9d | ||
|
|
0990dfadb5 | ||
|
|
e82dec7e01 | ||
|
|
6f455b565f | ||
|
|
7f5d25a03b | ||
|
|
87ee8cd097 | ||
|
|
803331e807 | ||
|
|
26de85a140 | ||
|
|
1ee85482f3 | ||
|
|
a5e5d414b4 | ||
|
|
e34a934460 | ||
|
|
19f82ad076 | ||
|
|
c933847201 | ||
|
|
cbedbba6fc | ||
|
|
ba2306656e | ||
|
|
5f251d555f | ||
|
|
02960cf886 | ||
|
|
b788fedec3 | ||
|
|
75dceba97b | ||
|
|
e61d57425e | ||
|
|
6d084a7b7a | ||
|
|
16f76a4236 | ||
|
|
d195518dfb | ||
|
|
711a084686 | ||
|
|
0c7132a7f2 | ||
|
|
249b693936 | ||
|
|
d35fb01306 | ||
|
|
1d98b273e7 | ||
|
|
3129333f9a | ||
|
|
86eb893f40 | ||
|
|
14c0dea7e4 | ||
|
|
561014c83f | ||
|
|
906b3b48cb | ||
|
|
fff6f9e4da | ||
|
|
cc1ec1653f | ||
|
|
26880016e9 | ||
|
|
e782b75bd9 | ||
|
|
eb55184118 | ||
|
|
71190488ca | ||
|
|
e69fbaf75e | ||
|
|
17bb527e1d | ||
|
|
1e206e8df3 | ||
|
|
bc87a04da5 | ||
|
|
96861bea06 | ||
|
|
e4f030c98d | ||
|
|
b3cf01dd3b | ||
|
|
fbee695aad | ||
|
|
cc4d3b72d2 | ||
|
|
e059c358b3 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Frontend build context (root Dockerfile) — keep it to what `vite build` needs.
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
android
|
||||
docs
|
||||
scripts
|
||||
backend
|
||||
scrobble
|
||||
.env
|
||||
*.log
|
||||
+31
-14
@@ -1,19 +1,36 @@
|
||||
# Copy to .env and fill in your values
|
||||
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
|
||||
# Used by docker-compose.yml (zieht fertige Images) und docker-compose.dev.yml (baut lokal).
|
||||
# REGISTRY/TAG unten braucht nur der Pull-Weg (docker-compose.yml).
|
||||
|
||||
# Pre-fills the URLs on the setup screen
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
|
||||
NAVIDROME_URL=https://music.example.com
|
||||
BACKEND_URL=https://backend.example.com
|
||||
SCROBBLE_URL=https://scrobble.example.com
|
||||
|
||||
# Absolute path to your Navidrome music library on the host
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
||||
# ── Library / downloads (backend) ────────────────────────────────────────────
|
||||
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
|
||||
MUSIC_PATH=/opt/navidrome/music
|
||||
AUDIO_FMT=mp3
|
||||
AUDIO_QUALITY=0
|
||||
MAX_PARALLEL=2
|
||||
|
||||
DOWNLOAD_BACKEND=ytdlp
|
||||
|
||||
# Ticketmaster Events (optional — free API key)
|
||||
# 1. Sign up at https://developer.ticketmaster.com/
|
||||
# 2. Create an app — copy the Consumer Key
|
||||
TICKETMASTER_API_KEY=
|
||||
|
||||
# ── Android APK download button + in-app updater ─────────────────────────────
|
||||
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
|
||||
GITEA_URL=http://192.168.2.51:3000 # Gitea-Basis, die der BACKEND-CONTAINER erreicht.
|
||||
# Muss nicht Giteas eigene ROOT_URL sein — das Backend baut
|
||||
# die Asset-URL auf diesen Host um (Test aus dem Container:
|
||||
# curl -sI $GITEA_URL/api/v1/version).
|
||||
GITEA_REPO=Docker/NovaSonic
|
||||
GITEA_TOKEN= # read token (only needed for a private repo)
|
||||
GITEA_APK_TAG=apk-latest
|
||||
|
||||
# ── Fertige Images (nur docker-compose.yml) ──────────────────────────────────
|
||||
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
|
||||
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
|
||||
# Docker Hub: docker.io/youruser
|
||||
# Plain-HTTP internal registries need the host's Docker daemon to allow it
|
||||
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
|
||||
REGISTRY=gitea.honeyy.duckdns.org/docker
|
||||
TAG=latest
|
||||
@@ -2,3 +2,6 @@
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
.env
|
||||
.idea/
|
||||
+1122
-27
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,7 @@ plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies
|
||||
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||||
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||||
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
||||
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
|
||||
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||||
metadata editor are **not** in the Android app).
|
||||
|
||||
@@ -40,9 +41,22 @@ npm run build # production build → dist/
|
||||
npm run preview # preview the production build
|
||||
```
|
||||
|
||||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
||||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
||||
(this also re-pulls the latest yt-dlp).
|
||||
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
|
||||
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
|
||||
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
|
||||
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
|
||||
|
||||
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
|
||||
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
|
||||
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
|
||||
Gitea-Registry (`…/docker`, Owner klein geschrieben).
|
||||
|
||||
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
|
||||
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
|
||||
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
|
||||
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
|
||||
|
||||
On the deploy host the music lives at `/opt/navidrome/music`.
|
||||
|
||||
## Download pipeline (hard-won knowledge)
|
||||
|
||||
@@ -79,6 +93,31 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
|
||||
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
|
||||
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
|
||||
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
|
||||
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
|
||||
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
|
||||
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
|
||||
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
|
||||
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
|
||||
which had rejected the correct official Art Track in every step. One exception: a **re-release year
|
||||
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
|
||||
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
|
||||
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
|
||||
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
|
||||
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
|
||||
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
|
||||
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
|
||||
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
|
||||
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
|
||||
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
|
||||
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
|
||||
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
|
||||
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
|
||||
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
|
||||
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
|
||||
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
|
||||
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
|
||||
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
|
||||
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
|
||||
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
||||
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||||
@@ -104,6 +143,17 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
||||
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
||||
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
|
||||
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
|
||||
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
|
||||
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
|
||||
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
|
||||
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
|
||||
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
|
||||
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
|
||||
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
|
||||
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
|
||||
(`deezer.test.js`, `musicbrainz.test.js`).
|
||||
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
||||
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
||||
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||||
@@ -115,6 +165,44 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||||
|
||||
## Listening stats / scrobble system (v2.0)
|
||||
|
||||
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
|
||||
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
|
||||
records every counted play into its **own** timestamped, multi-user log.
|
||||
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
|
||||
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
|
||||
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
|
||||
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
|
||||
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
|
||||
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
|
||||
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
|
||||
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
|
||||
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
|
||||
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
|
||||
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
|
||||
`_toEpochSec`, which maps `0`→now).
|
||||
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
|
||||
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
|
||||
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
|
||||
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
|
||||
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
|
||||
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
|
||||
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
|
||||
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
|
||||
batch (server dedups by uuid) on start / when connectivity returns.
|
||||
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
|
||||
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
|
||||
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
|
||||
cover-agnostic; the stats endpoint just returns the IDs.
|
||||
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
|
||||
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
|
||||
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
|
||||
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
|
||||
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
|
||||
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
|
||||
`docs/scrobble-plan.md`.
|
||||
|
||||
## Android offline system
|
||||
|
||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||
@@ -132,6 +220,12 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
||||
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
|
||||
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
|
||||
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
|
||||
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
|
||||
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
|
||||
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
|
||||
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
|
||||
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
|
||||
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
|
||||
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||||
|
||||
@@ -10,11 +10,12 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
|
||||
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks.
|
||||
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
|
||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
|
||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
|
||||
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
|
||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||
|
||||
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
||||
@@ -28,7 +29,8 @@ NovaSonic/
|
||||
├── src/
|
||||
│ ├── player/
|
||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
||||
│ │ └── nowplaying.js # Now Playing overlay + synced lyrics
|
||||
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
|
||||
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
|
||||
│ ├── views/
|
||||
│ │ ├── detail.js # Artist / album detail pages
|
||||
│ │ ├── views.js # Navigation, page loaders, search
|
||||
@@ -48,6 +50,7 @@ NovaSonic/
|
||||
│ │ ├── metadata.js # Metadata editor
|
||||
│ │ ├── modals.js # Song info, events modals
|
||||
│ │ ├── notify.js # Toast notifications
|
||||
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||
│ │ └── settings.js # Settings page
|
||||
│ ├── state.js # Shared mutable state (S object)
|
||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||
@@ -63,6 +66,8 @@ NovaSonic/
|
||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||
│ └── romaji.js # Japanese → romaji search index
|
||||
├── scrobble/ # Isolated listening-stats service (own container + DB)
|
||||
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
|
||||
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||
@@ -82,9 +87,10 @@ NovaSonic/
|
||||
├── styles.css
|
||||
├── vite.config.js
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
|
||||
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
|
||||
├── nginx.conf
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL at runtime
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||
```
|
||||
|
||||
---
|
||||
@@ -104,6 +110,7 @@ Edit `.env`:
|
||||
```env
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
@@ -117,10 +124,26 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Frontend on port 8080, backend on port 3700. Route both through your reverse proxy.
|
||||
Zieht die drei fertigen Images aus der Registry — nichts wird auf dem Server gebaut. Dafür in der
|
||||
`.env` `REGISTRY` (und optional `TAG`) setzen; die Images heißen
|
||||
`$REGISTRY/novasonic-frontend|-backend|-scrobble`. Neue Stände lädt man von der Dev-Maschine mit
|
||||
`npm run push-images` hoch (bzw. `docker build -t $REGISTRY/<name>:latest <context> && docker push …`
|
||||
— Kontexte: `.` (frontend), `backend/`, `scrobble/`).
|
||||
|
||||
**Selbst bauen statt ziehen** (keine Registry nötig):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Baut lokal aus den Dockerfiles; die Deps stecken in den Images, also sind Restarts sofort da und
|
||||
Rebuilds nach einem `git pull` dank Layer-Cache schnell.
|
||||
|
||||
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
|
||||
|
||||
### 3. First launch
|
||||
|
||||
@@ -128,6 +151,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
||||
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||
3. Optionally connect Audiobookshelf in Settings
|
||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
||||
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
|
||||
|
||||
---
|
||||
|
||||
@@ -139,6 +163,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
||||
|---|---|
|
||||
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
||||
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
|
||||
|
||||
### Backend container
|
||||
|
||||
@@ -151,6 +176,14 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
||||
|
||||
### Scrobble container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3710` | Port the scrobble service listens on |
|
||||
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
|
||||
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
@@ -14,8 +14,35 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 30
|
||||
versionName = "1.27"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
|
||||
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
|
||||
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
|
||||
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
|
||||
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
|
||||
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
|
||||
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
|
||||
manifestPlaceholders["shareHost"] = host1
|
||||
manifestPlaceholders["shareHost2"] = host2
|
||||
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
|
||||
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
|
||||
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
|
||||
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
|
||||
|
||||
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
|
||||
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
|
||||
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
|
||||
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
|
||||
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
|
||||
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
|
||||
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
|
||||
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
|
||||
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
|
||||
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
Vendored
+6
@@ -6,3 +6,9 @@
|
||||
# Keep Media3 session / exoplayer classes used by reflection / service binding.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
|
||||
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
|
||||
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
|
||||
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
|
||||
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
|
||||
-dontwarn org.slf4j.**
|
||||
@@ -8,6 +8,8 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
@@ -31,6 +33,19 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
|
||||
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
|
||||
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
|
||||
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
|
||||
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="${shareHost}"/>
|
||||
<data android:scheme="https" android:host="${shareHost2}"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
@@ -44,6 +59,17 @@
|
||||
</service>
|
||||
|
||||
|
||||
<!-- FileProvider — hands the downloaded update APK to the system installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
|
||||
@@ -6,6 +6,10 @@ import com.novasonic.app.offline.OfflineRepository
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PreviewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NovaSonicApp : Application() {
|
||||
|
||||
@@ -20,13 +24,45 @@ class NovaSonicApp : Application() {
|
||||
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
|
||||
val previewController: PreviewController = PreviewController()
|
||||
|
||||
/**
|
||||
* Raeumt den Cache nach einem App-Update einmalig auf.
|
||||
*
|
||||
* Im cacheDir liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates — beides
|
||||
* jederzeit neu beschaffbar. Nach einem Update konnten dort Reste liegen, die zu alten Anzeigen
|
||||
* fuehrten; die einzige Abhilfe war bisher "Cache leeren" von Hand in den Systemeinstellungen,
|
||||
* was man einem normalen Nutzer nicht erklaeren moechte. Das passiert jetzt automatisch und
|
||||
* ausschliesslich beim Versionswechsel, nicht im laufenden Betrieb — sonst waeren die Cover bei
|
||||
* jedem Start neu zu laden.
|
||||
*
|
||||
* Offline-Downloads sind NICHT betroffen: die liegen unter filesDir/offline, nicht im cacheDir.
|
||||
*/
|
||||
private fun cacheNachUpdateLeeren() {
|
||||
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
|
||||
val zuletzt = prefs.getInt("last_version_code", -1)
|
||||
val jetzt = BuildConfig.VERSION_CODE
|
||||
if (zuletzt == jetzt) return
|
||||
if (zuletzt != -1) {
|
||||
runCatching { cacheDir.deleteRecursively() }
|
||||
.onSuccess { android.util.Log.i("NsCache", "Cache nach Update $zuletzt -> $jetzt geleert") }
|
||||
.onFailure { android.util.Log.w("NsCache", "Cache-Leeren fehlgeschlagen", it) }
|
||||
}
|
||||
prefs.edit().putInt("last_version_code", jetzt).apply()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
cacheNachUpdateLeeren()
|
||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
absPlayerController = AbsPlayerController(applicationContext)
|
||||
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
|
||||
offlineRepository.setzeUnvollstaendigeFort()
|
||||
// Flush any plays queued while offline to the scrobble service.
|
||||
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
runCatching { repository.flushPlayLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Reads/writes the shared artist-pin overrides on the download backend
|
||||
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
|
||||
* The pins fix same-name artist collisions; see [ArtistOverride].
|
||||
*/
|
||||
class ArtistOverrideClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
@Serializable
|
||||
private data class SetReq(
|
||||
val ndArtistId: String,
|
||||
val deezerArtistId: String,
|
||||
val name: String,
|
||||
val image: String,
|
||||
val fanCount: Long?,
|
||||
)
|
||||
|
||||
/** Full map { ndArtistId -> override }. */
|
||||
suspend fun loadAll(): Map<String, ArtistOverride> =
|
||||
http.get("${base()}/api/artist-overrides").body()
|
||||
|
||||
/** Upsert a pin. Returns true on 2xx. */
|
||||
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
|
||||
}
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
/** Remove a pin. Returns true on 2xx. */
|
||||
suspend fun remove(ndArtistId: String): Boolean {
|
||||
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.novasonic.app.api.model.ConcertEvent
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
|
||||
object EventsClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
|
||||
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
|
||||
expectSuccess = true
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
|
||||
suspend fun status(jobId: String): DownloadStatus =
|
||||
http.get("${base()}/api/status/$jobId").body()
|
||||
|
||||
/**
|
||||
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
|
||||
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
|
||||
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
|
||||
*/
|
||||
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
|
||||
val res = http.post("${base()}/api/delete-song") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
|
||||
}
|
||||
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -64,12 +64,24 @@ object ItunesClient {
|
||||
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
||||
}
|
||||
|
||||
/** A Deezer artist's real albums (not singles/EPs). */
|
||||
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
|
||||
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
|
||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
||||
parameter("limit", 200)
|
||||
}.body()
|
||||
return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
|
||||
val rank = { rt: String? -> when (rt) { "album" -> 0; "ep" -> 1; "single" -> 2; else -> 3 } }
|
||||
return albums.data.sortedBy { rank(it.record_type) }.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
|
||||
* route when /search/artist can't surface a low-profile same-name artist — used by the
|
||||
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
|
||||
*/
|
||||
suspend fun lookupArtist(artistId: Long): ItunesItem? {
|
||||
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
|
||||
return if (a?.id != null) a.toItunesItem() else null
|
||||
}
|
||||
|
||||
/** Top tracks for a Deezer artist. */
|
||||
@@ -80,6 +92,18 @@ object ItunesClient {
|
||||
return tracks.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
|
||||
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
|
||||
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
|
||||
*/
|
||||
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
|
||||
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return artists.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
|
||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
|
||||
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
|
||||
*/
|
||||
object ReleasesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
expectSuccess = true
|
||||
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
|
||||
// Danach liegt es dort im Cache.
|
||||
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
|
||||
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||
// Ohne `days`: das Backend liefert alles Angekuendigte.
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistKonzerteRequest(artists))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,16 @@ class SubsonicClient(
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
/**
|
||||
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||||
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
|
||||
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
|
||||
*/
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", buildMap {
|
||||
put("artist", artist); put("count", count)
|
||||
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
}).topSongs?.song ?: emptyList()
|
||||
|
||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||
get("getGenres.view").genres?.genre ?: emptyList()
|
||||
@@ -163,6 +170,9 @@ class SubsonicClient(
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
|
||||
suspend fun startScan() = get("startScan.view")
|
||||
|
||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||
val params = mutableListOf("name" to name)
|
||||
songIds.forEach { params += "songId" to it }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
|
||||
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
|
||||
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
|
||||
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
|
||||
*/
|
||||
@Serializable
|
||||
data class ArtistOverride(
|
||||
val deezerArtistId: String = "",
|
||||
val name: String = "",
|
||||
val image: String = "",
|
||||
val fanCount: Long? = null,
|
||||
)
|
||||
@@ -42,6 +42,15 @@ data class DownloadRequest(
|
||||
@Serializable
|
||||
data class ResolveRequest(val url: String)
|
||||
|
||||
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
|
||||
@Serializable
|
||||
data class DeleteSongRequest(
|
||||
val ndUrl: String,
|
||||
val user: String,
|
||||
val pass: String,
|
||||
val songId: String,
|
||||
)
|
||||
|
||||
/** Response from GET {backend}/api/status/{jobId} */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
|
||||
@Serializable
|
||||
data class ArtistRelease(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val deezerArtistId: String? = null,
|
||||
val albumId: String = "",
|
||||
val title: String = "",
|
||||
val cover: String = "",
|
||||
val releaseDate: String = "",
|
||||
val recordType: String = "album",
|
||||
val trackCount: Int? = null,
|
||||
val explicit: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesResponse(
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
|
||||
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||
@Serializable
|
||||
data class ArtistKonzert(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val eventId: String = "",
|
||||
val titel: String = "",
|
||||
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||
val datum: String = "",
|
||||
val ort: String = "",
|
||||
val stadt: String = "",
|
||||
val land: String = "",
|
||||
val url: String = "",
|
||||
val abgesagt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteResponse(
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||
val ohneTermine: List<String> = emptyList(),
|
||||
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||
val keinSchluessel: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
||||
@Serializable
|
||||
data class ScrobbleStats(
|
||||
val firstPlayedAt: Long? = null,
|
||||
val totalPlays: Int = 0,
|
||||
val totalSeconds: Long = 0,
|
||||
val topArtists: List<StatArtist> = emptyList(),
|
||||
val topAlbums: List<StatAlbum> = emptyList(),
|
||||
val topTracks: List<StatTrack> = emptyList(),
|
||||
val topGenres: List<StatGenre> = emptyList(),
|
||||
val series: List<StatBucket> = emptyList(),
|
||||
val byHour: List<StatHour> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatGenre(
|
||||
val name: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatAlbum(
|
||||
val name: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatTrack(
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songId: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatBucket(
|
||||
val bucket: String = "",
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatHour(
|
||||
val hour: Int = 0,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
|
||||
// GET /scrobble/users — distinct users in the log, for the partner picker.
|
||||
@Serializable
|
||||
data class ScrobbleUsersResponse(
|
||||
val me: String = "",
|
||||
val users: List<ScrobbleUser> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScrobbleUser(
|
||||
val user: String = "",
|
||||
val plays: Int = 0,
|
||||
val lastPlayedAt: Long? = null,
|
||||
)
|
||||
|
||||
// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B).
|
||||
@Serializable
|
||||
data class CompareResult(
|
||||
val userA: String = "",
|
||||
val userB: String = "",
|
||||
val matchScore: Int = 0,
|
||||
val battle: Battle? = null,
|
||||
val totalsA: CompareTotals = CompareTotals(),
|
||||
val totalsB: CompareTotals = CompareTotals(),
|
||||
val sharedArtists: List<SharedArtist> = emptyList(),
|
||||
val sharedTracks: List<SharedTrack> = emptyList(),
|
||||
val onlyA: List<UniqueArtist> = emptyList(),
|
||||
val onlyB: List<UniqueArtist> = emptyList(),
|
||||
)
|
||||
|
||||
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
|
||||
@Serializable
|
||||
data class Battle(
|
||||
val kategorien: List<BattleKategorie> = emptyList(),
|
||||
val punkteA: Int = 0,
|
||||
val punkteB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BattleKategorie(
|
||||
val key: String = "",
|
||||
val label: String = "",
|
||||
val textA: String = "",
|
||||
val textB: String = "",
|
||||
val sieger: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompareTotals(
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
val artists: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val playsA: Int = 0,
|
||||
val playsB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedTrack(
|
||||
val songId: String? = null,
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val albumId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val playsA: Int = 0,
|
||||
val playsB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UniqueArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.novasonic.app.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
|
||||
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
|
||||
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
|
||||
* The user still confirms the system install (Android requires that for sideloaded APKs).
|
||||
*
|
||||
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
|
||||
* each download. An update (same package + signature) is an in-place install, so app data / offline
|
||||
* downloads survive — provided the signing key stays stable across builds.
|
||||
*/
|
||||
object AppUpdater {
|
||||
|
||||
@Serializable
|
||||
data class ApkInfo(
|
||||
val available: Boolean = false,
|
||||
val version: String? = null,
|
||||
val versionCode: Int? = null,
|
||||
val size: Long? = null,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _available = MutableStateFlow<ApkInfo?>(null)
|
||||
val available: StateFlow<ApkInfo?> = _available
|
||||
|
||||
private val _downloading = MutableStateFlow(false)
|
||||
val downloading: StateFlow<Boolean> = _downloading
|
||||
|
||||
// Versions the user waved away this session — don't nag again until app restart.
|
||||
@Volatile private var dismissedCode: Int = -1
|
||||
|
||||
suspend fun check(backendUrl: String) {
|
||||
if (backendUrl.isBlank()) return
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
c.inputStream.bufferedReader().use { it.readText() }
|
||||
.let { json.decodeFromString<ApkInfo>(it) }
|
||||
}.getOrNull()
|
||||
} ?: return
|
||||
val vc = info.versionCode ?: return // no reliable integer → don't prompt
|
||||
if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) {
|
||||
_available.value = info
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
dismissedCode = _available.value?.versionCode ?: dismissedCode
|
||||
_available.value = null
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) {
|
||||
if (_downloading.value || backendUrl.isBlank()) return
|
||||
_downloading.value = true
|
||||
try {
|
||||
val apk = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } }
|
||||
val out = File(dir, "NovaSonic-update.apk")
|
||||
val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection)
|
||||
.apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true }
|
||||
if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}")
|
||||
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||
out
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
_available.value = null // installer takes over
|
||||
} catch (e: Exception) {
|
||||
onError(e.message ?: "Download fehlgeschlagen")
|
||||
} finally {
|
||||
_downloading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
|
||||
*
|
||||
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
|
||||
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
|
||||
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
|
||||
*/
|
||||
object DeepLinkState {
|
||||
val pending = MutableStateFlow<Uri?>(null)
|
||||
|
||||
fun offer(uri: Uri?) {
|
||||
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
|
||||
// App-Symbol darf keine Navigation ausloesen.
|
||||
if (uri == null) return
|
||||
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
|
||||
pending.value = uri
|
||||
}
|
||||
|
||||
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
|
||||
fun consume(): Uri? = pending.value.also { pending.value = null }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
/**
|
||||
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
|
||||
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
|
||||
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
|
||||
*/
|
||||
object LibraryIntent {
|
||||
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
|
||||
var oeffneGefolgte: Boolean = false
|
||||
}
|
||||
@@ -32,6 +32,7 @@ object ClientHolder {
|
||||
class MusicRepository(private val context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
private val playLog = PlayLogClient(context)
|
||||
|
||||
private suspend fun api(): SubsonicClient =
|
||||
ClientHolder.get(prefs.credentials.first())
|
||||
@@ -56,12 +57,59 @@ class MusicRepository(private val context: Context) {
|
||||
suspend fun star(id: String) = api().star(id)
|
||||
suspend fun unstar(id: String) = api().unstar(id)
|
||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
|
||||
suspend fun logPlay(song: Song) = playLog.logPlay(song)
|
||||
suspend fun flushPlayLog() = playLog.flush()
|
||||
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
||||
playLog.fetchStats(from, to, tz, bucket, limit)
|
||||
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
||||
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
||||
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
|
||||
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
|
||||
|
||||
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
|
||||
suspend fun artistOverrides(): Map<String, ArtistOverride> {
|
||||
_artistOverrides?.let { return it }
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
|
||||
c.close()
|
||||
return m.also { _artistOverrides = it }
|
||||
}
|
||||
|
||||
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
|
||||
|
||||
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
|
||||
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
|
||||
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
|
||||
api().getTopSongs(artistName, artistId = artistId)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
suspend fun startScan() = api().startScan()
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
||||
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
||||
@@ -124,6 +172,9 @@ class MusicRepository(private val context: Context) {
|
||||
|
||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||
|
||||
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
|
||||
prefs.saveQueueState(songs, index)
|
||||
suspend fun readQueueState() = prefs.readQueueState()
|
||||
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
||||
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.api.model.Song
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
|
||||
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
|
||||
*
|
||||
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
|
||||
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
|
||||
* (see PlayerController.scheduleScrobble).
|
||||
*
|
||||
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
|
||||
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
|
||||
*
|
||||
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
|
||||
*/
|
||||
class PlayLogClient(context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
private val queueFile = File(context.filesDir, "play-queue.json")
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PlayEntry(
|
||||
val uuid: String,
|
||||
val songId: String,
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val genre: String? = null,
|
||||
val duration: Int? = null,
|
||||
val playedAt: Long,
|
||||
val source: String = "android",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class Batch(val plays: List<PlayEntry>)
|
||||
|
||||
/** Queue one counted play and try to deliver it. */
|
||||
suspend fun logPlay(song: Song) {
|
||||
val s = prefs.appSettings.first()
|
||||
if (s.scrobbleUrl.isBlank()) return
|
||||
val entry = PlayEntry(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
songId = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artistId = song.artistId,
|
||||
album = song.album,
|
||||
albumId = song.albumId,
|
||||
coverArt = song.coverArt,
|
||||
genre = song.genre,
|
||||
duration = song.duration,
|
||||
playedAt = System.currentTimeMillis() / 1000,
|
||||
)
|
||||
mutex.withLock {
|
||||
val q = readQueue().toMutableList()
|
||||
q.add(entry)
|
||||
writeQueue(q.takeLast(MAX_QUEUE))
|
||||
}
|
||||
flush(s)
|
||||
}
|
||||
|
||||
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
|
||||
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
|
||||
val s = settings ?: prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
|
||||
|
||||
val batch = mutex.withLock { readQueue() }
|
||||
if (batch.isEmpty()) return
|
||||
|
||||
val auth = authParams(s.username, s.password)
|
||||
val ok = runCatching {
|
||||
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(Batch(batch)))
|
||||
}
|
||||
resp.status.isSuccess()
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (ok) {
|
||||
// Remove exactly what we sent (accepted + duplicates are durable), preserving
|
||||
// any plays queued while the request was in flight.
|
||||
val sent = batch.map { it.uuid }.toHashSet()
|
||||
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
|
||||
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
|
||||
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
|
||||
return runCatching {
|
||||
val resp = http.get(url)
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */
|
||||
suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
return runCatching {
|
||||
val resp = http.get("$base/scrobble/users?$auth")
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<com.novasonic.app.api.model.ScrobbleUsersResponse>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Head-to-Head: compare the authed user against [with]. Null on failure. */
|
||||
suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}"
|
||||
return runCatching {
|
||||
val resp = http.get(url)
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readQueue(): List<PlayEntry> =
|
||||
runCatching {
|
||||
if (!queueFile.exists()) emptyList()
|
||||
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun writeQueue(q: List<PlayEntry>) {
|
||||
runCatching { queueFile.writeText(json.encodeToString(q)) }
|
||||
}
|
||||
|
||||
private fun authParams(user: String, pass: String): String {
|
||||
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||
val token = md5(pass + salt)
|
||||
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
|
||||
}
|
||||
|
||||
private fun md5(input: String): String =
|
||||
MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
companion object { private const val MAX_QUEUE = 500 }
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package com.novasonic.app.data
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||
@@ -18,16 +20,26 @@ class PrefsStore(private val context: Context) {
|
||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||
val KEY_THEME = stringPreferencesKey("theme")
|
||||
val KEY_ABS_URL = stringPreferencesKey("abs_url")
|
||||
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
|
||||
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
||||
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
|
||||
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
|
||||
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
|
||||
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
||||
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
|
||||
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
|
||||
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
|
||||
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
|
||||
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
|
||||
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
|
||||
}
|
||||
|
||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||
@@ -40,12 +52,23 @@ class PrefsStore(private val context: Context) {
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
|
||||
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
|
||||
val downloadWifiOnly: Boolean = true,
|
||||
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
|
||||
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
|
||||
val wischenWechseltTitel: Boolean = true,
|
||||
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
|
||||
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
|
||||
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
|
||||
val lyricsFolgenAutomatisch: Boolean = false,
|
||||
)
|
||||
|
||||
data class LastPlayedSong(
|
||||
@@ -57,7 +80,9 @@ class PrefsStore(private val context: Context) {
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
Credentials(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
// Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
|
||||
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
@@ -65,17 +90,26 @@ class PrefsStore(private val context: Context) {
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_BACKEND_URL,
|
||||
// Leerer Speicherwert => eingebaute Vorgabe. So muss auf einem frischen Geraet nichts
|
||||
// eingetippt werden, ein eigener Eintrag hat aber weiterhin Vorrang.
|
||||
scrobbleUrl = prefs[KEY_SCROBBLE_URL]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_SCROBBLE_URL,
|
||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||
absUrl = prefs[KEY_ABS_URL] ?: "",
|
||||
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
|
||||
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
||||
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
|
||||
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
|
||||
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,15 +138,28 @@ class PrefsStore(private val context: Context) {
|
||||
prefs[KEY_PASSWORD] = settings.password
|
||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
|
||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||
prefs[KEY_THEME] = settings.theme
|
||||
prefs[KEY_ABS_URL] = settings.absUrl
|
||||
prefs[KEY_ABS_API_KEY] = settings.absApiKey
|
||||
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
||||
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
|
||||
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
|
||||
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
|
||||
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
|
||||
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
|
||||
// dieses Feld gar nicht mit.
|
||||
}
|
||||
}
|
||||
|
||||
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
|
||||
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
|
||||
suspend fun saveLyricsFolgen(an: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
@@ -126,6 +173,39 @@ class PrefsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
|
||||
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
|
||||
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
|
||||
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
|
||||
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
|
||||
*/
|
||||
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
|
||||
val kodiert = runCatching {
|
||||
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), songs)
|
||||
}.getOrNull() ?: return
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_QUEUE_JSON] = kodiert
|
||||
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
|
||||
|
||||
suspend fun readQueueState(): QueueState? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val songs = runCatching {
|
||||
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), roh)
|
||||
}.getOrNull().orEmpty()
|
||||
if (songs.isEmpty()) return null
|
||||
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -21,13 +21,18 @@ class DownloadWorker(
|
||||
|
||||
companion object {
|
||||
const val EXTRA_GROUP_ID = "group_id"
|
||||
private const val TAG = "DownloadWorker"
|
||||
/** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel
|
||||
* (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */
|
||||
private const val MAX_VERSUCHE = 20
|
||||
}
|
||||
|
||||
private val offlineRepo = applicationContext.app.offlineRepository
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
||||
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
// Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
|
||||
offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
val songIds = offlineRepo.songIdsForGroup(groupId)
|
||||
if (songIds.isEmpty()) return Result.success()
|
||||
|
||||
@@ -35,14 +40,26 @@ class DownloadWorker(
|
||||
if (!creds.isValid) return Result.failure()
|
||||
|
||||
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
var done = group.downloadedTracks
|
||||
// Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
|
||||
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
|
||||
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
|
||||
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
|
||||
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
|
||||
var done = 0
|
||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||
|
||||
return try {
|
||||
var gestoppt = false
|
||||
for (songId in songIds) {
|
||||
// WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann.
|
||||
// Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen
|
||||
// wird — der angefangene Titel geht verloren und niemand startet neu. So steigen
|
||||
// wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen.
|
||||
if (isStopped) { gestoppt = true; break }
|
||||
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
|
||||
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
|
||||
done++
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -69,17 +86,41 @@ class DownloadWorker(
|
||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||
done++
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Failed to download song $songId", e)
|
||||
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
|
||||
audioFile.delete()
|
||||
offlineRepo.markFailed(songId)
|
||||
}
|
||||
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
}
|
||||
// Beim Abbruch ist der letzte Stand sonst nicht gesichert.
|
||||
if (gestoppt) dao.updateGroupProgress(groupId, done)
|
||||
|
||||
val offen = dao.offeneTitel(groupId)
|
||||
when {
|
||||
gestoppt -> {
|
||||
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
|
||||
Result.retry()
|
||||
}
|
||||
offen == 0 -> {
|
||||
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
|
||||
Result.success()
|
||||
}
|
||||
runAttemptCount < MAX_VERSUCHE -> {
|
||||
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
|
||||
Result.retry()
|
||||
}
|
||||
else -> {
|
||||
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
|
||||
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
|
||||
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
||||
Result.failure()
|
||||
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
|
||||
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
|
||||
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,26 @@ interface OfflineDao {
|
||||
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
||||
|
||||
/**
|
||||
* Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel
|
||||
* neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks
|
||||
* uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar
|
||||
* und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren.
|
||||
* Eine Anweisung fuer alle Gruppen, laeuft beim App-Start.
|
||||
*/
|
||||
@Query("""
|
||||
UPDATE download_groups SET downloadedTracks = (
|
||||
SELECT COUNT(*) FROM group_track_cross_refs r
|
||||
INNER JOIN downloaded_tracks t ON t.songId = r.songId
|
||||
WHERE r.groupId = download_groups.groupId AND t.state = 'DONE'
|
||||
)
|
||||
""")
|
||||
suspend fun fortschrittNeuBerechnen()
|
||||
|
||||
/** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */
|
||||
@Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks")
|
||||
suspend fun incompleteGroups(): List<DownloadGroupEntity>
|
||||
|
||||
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun deleteGroup(groupId: String)
|
||||
|
||||
@@ -84,9 +104,21 @@ interface OfflineDao {
|
||||
""")
|
||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId AND t.state != 'DONE'
|
||||
""")
|
||||
suspend fun offeneTitel(groupId: String): Int
|
||||
|
||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||
|
||||
/** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */
|
||||
@Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId")
|
||||
suspend fun titelMitCover(coverArtId: String): Int
|
||||
|
||||
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
|
||||
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
|
||||
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
|
||||
|
||||
@@ -154,18 +154,98 @@ class OfflineRepository(context: Context) {
|
||||
}
|
||||
dao.updateGroupProgress(groupId, alreadyDone)
|
||||
|
||||
starteWorker(groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
|
||||
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
|
||||
*/
|
||||
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
|
||||
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
|
||||
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
|
||||
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
|
||||
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
|
||||
val nurWlan = runCatching {
|
||||
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
|
||||
}.getOrDefault(true)
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
)
|
||||
// Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem
|
||||
// Geraet Luft und halten die Kette trotzdem in Gang.
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
|
||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||
groupId,
|
||||
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
|
||||
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
|
||||
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
|
||||
* ohnehin fortsetzt statt von vorn zu laden.
|
||||
*/
|
||||
fun netzbedingungAktualisieren() {
|
||||
scope.launch {
|
||||
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||
.forEach { starteWorker(it.groupId, ersetzen = true) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen
|
||||
* Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele
|
||||
* Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch
|
||||
* einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt
|
||||
* darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt.
|
||||
*/
|
||||
fun setzeUnvollstaendigeFort() {
|
||||
scope.launch {
|
||||
// Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus
|
||||
// der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden.
|
||||
runCatching { dao.fortschrittNeuBerechnen() }
|
||||
.onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) }
|
||||
val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||
offen.forEach { g ->
|
||||
Log.i("OfflineRepository",
|
||||
"Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})")
|
||||
starteWorker(g.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle Dateien eines Titels und dann die Datenbankzeile.
|
||||
*
|
||||
* Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder
|
||||
* fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus
|
||||
* der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr
|
||||
* benutzt (Cover werden geteilt gespeichert).
|
||||
*/
|
||||
private suspend fun loescheTitelVollstaendig(songId: String) {
|
||||
val track = dao.getTrackById(songId)
|
||||
val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId])
|
||||
pfade.distinct().forEach { p ->
|
||||
runCatching { File(p).delete(); File("$p.tmp").delete() }
|
||||
}
|
||||
track?.coverArt?.let { coverArtId ->
|
||||
// Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer.
|
||||
if (dao.titelMitCover(coverArtId) <= 1) {
|
||||
runCatching { File(coversDir(), "$coverArtId.jpg").delete() }
|
||||
track.coverPath?.let { runCatching { File(it).delete() } }
|
||||
}
|
||||
}
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
|
||||
// ── Delete with reference-count file cleanup ──────────────────────────────
|
||||
@@ -173,22 +253,18 @@ class OfflineRepository(context: Context) {
|
||||
/** Removes a group and any tracks that are no longer referenced by another group. */
|
||||
suspend fun deleteGroup(groupId: String) {
|
||||
val songIds = dao.songIdsForGroup(groupId)
|
||||
// Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere.
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(groupId)
|
||||
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
||||
for (songId in songIds) {
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
||||
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
||||
dao.removeCrossRef(groupId, songId)
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||
recountGroup(groupId)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ class MusicService : MediaLibraryService() {
|
||||
// Pseudo-items for play-all / shuffle at top of each list
|
||||
const val PLAY_ALL_PREFIX = "playall:"
|
||||
const val SHUFFLE_PREFIX = "shuffle:"
|
||||
// Encodes a song's browse context so a tap plays the whole list from that song:
|
||||
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
|
||||
const val SONG_CTX_PREFIX = "songctx:"
|
||||
// ABS session commands (handled via onCustomCommand)
|
||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||
@@ -235,7 +238,14 @@ class MusicService : MediaLibraryService() {
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
|
||||
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
|
||||
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
|
||||
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
@@ -349,6 +359,10 @@ class MusicService : MediaLibraryService() {
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
mediaId.startsWith(SONG_CTX_PREFIX) -> {
|
||||
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
|
||||
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
@@ -360,42 +374,78 @@ class MusicService : MediaLibraryService() {
|
||||
return settable
|
||||
}
|
||||
|
||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
||||
// Android Auto "add to queue" path — resolve browsable items to their songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
|
||||
catch (e: Exception) { settable.set(mediaItems) }
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
|
||||
// tapping a song plays its whole list from that song (not just the one song).
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||
scope.launch {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
||||
}
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
else -> listOf(item)
|
||||
}
|
||||
}
|
||||
settable.set(resolved.toMutableList())
|
||||
val (list, idx) = expandForPlayback(mediaItems)
|
||||
// A single browse tap expands into a full list → start at the tapped index.
|
||||
// Otherwise honour the caller's startIndex.
|
||||
val expanded = list.size != mediaItems.size
|
||||
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||
} catch (e: Exception) {
|
||||
settable.set(mediaItems)
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
|
||||
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
|
||||
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
|
||||
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
|
||||
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
|
||||
val ctx = rest.substringBefore('|')
|
||||
val songId = rest.substringAfter('|')
|
||||
val full = resolveSongs(ctx, shuffle = false)
|
||||
if (full.isNotEmpty()) {
|
||||
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
|
||||
return full to idx
|
||||
}
|
||||
return listOf(reattachUri(items[0], songId)) to 0
|
||||
}
|
||||
val resolved = items.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
|
||||
else -> listOf(reattachUri(item))
|
||||
}
|
||||
}
|
||||
return resolved to 0
|
||||
}
|
||||
|
||||
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
|
||||
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
|
||||
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
|
||||
return item.buildUpon().setMediaId(songId).setUri(uri).build()
|
||||
}
|
||||
|
||||
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||
@@ -432,16 +482,16 @@ class MusicService : MediaLibraryService() {
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||
FAVORITES_ID -> {
|
||||
val songs = repo.getStarred().song
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
|
||||
}
|
||||
else -> when {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
@@ -449,6 +499,13 @@ class MusicService : MediaLibraryService() {
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
|
||||
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
|
||||
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
|
||||
song.toMediaItem(repo).buildUpon()
|
||||
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
|
||||
.build()
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
|
||||
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val TAG_QUEUE = "NsQueue"
|
||||
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
|
||||
@@ -56,24 +58,95 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
controller = runCatching { future.get() }.getOrNull()
|
||||
controller?.addListener(PlayerListener())
|
||||
syncState()
|
||||
restoreQueue()
|
||||
}, context.mainExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
|
||||
* Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
|
||||
* Fortschritt auf dem Server.
|
||||
*/
|
||||
private fun persistQueueState() {
|
||||
val ctrl = controller ?: return
|
||||
val songs = queue
|
||||
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
|
||||
val index = ctrl.currentMediaItemIndex
|
||||
scope.launch {
|
||||
runCatching { repo.saveQueueState(songs, index) }
|
||||
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
|
||||
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
|
||||
fun saveQueueNow() = persistQueueState()
|
||||
|
||||
/**
|
||||
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
|
||||
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
|
||||
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
|
||||
*
|
||||
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
|
||||
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
|
||||
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
|
||||
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
|
||||
*/
|
||||
fun restoreQueue() {
|
||||
scope.launch {
|
||||
val zustand = runCatching { repo.readQueueState() }.getOrNull()
|
||||
if (zustand == null) {
|
||||
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
|
||||
return@launch
|
||||
}
|
||||
val ctrl = controller
|
||||
if (ctrl == null) {
|
||||
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
|
||||
return@launch
|
||||
}
|
||||
if (ctrl.mediaItemCount > 0) {
|
||||
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
|
||||
return@launch
|
||||
}
|
||||
val songs = zustand.songs
|
||||
val index = zustand.index.coerceIn(0, songs.lastIndex)
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
runCatching {
|
||||
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
|
||||
ctrl.prepare()
|
||||
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
|
||||
syncState()
|
||||
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
|
||||
*
|
||||
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
|
||||
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
|
||||
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
|
||||
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
|
||||
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
|
||||
*/
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
// If ABS is active, close it — user explicitly switched to music
|
||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||
if (abs.isActive) abs.close()
|
||||
}
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
val gemischt = startIndex < 0
|
||||
val reihenfolge = if (gemischt) songs.shuffled() else songs
|
||||
queue = reihenfolge
|
||||
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
|
||||
isManuallyShuffled = gemischt
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 0L)
|
||||
val items = reihenfolge.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
syncState()
|
||||
@@ -142,6 +215,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
// ── Warteschlange bearbeiten ────────────────────────────────────────────
|
||||
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
|
||||
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
|
||||
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
|
||||
|
||||
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
|
||||
fun entferneAusQueue(index: Int) {
|
||||
val ctrl = controller ?: return
|
||||
if (index !in queue.indices) return
|
||||
val raus = queue[index]
|
||||
queue = queue.toMutableList().apply { removeAt(index) }
|
||||
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
|
||||
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
|
||||
originalQueue = originalQueue.toMutableList().apply {
|
||||
val i = indexOfFirst { it.id == raus.id }
|
||||
if (i >= 0) removeAt(i)
|
||||
}
|
||||
ctrl.removeMediaItem(index)
|
||||
if (queue.isEmpty()) {
|
||||
ctrl.stop()
|
||||
ctrl.clearMediaItems()
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Verschiebt Position [von] auf [nach]. */
|
||||
fun verschiebeInQueue(von: Int, nach: Int) {
|
||||
val ctrl = controller ?: return
|
||||
if (von == nach || von !in queue.indices || nach !in queue.indices) return
|
||||
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
|
||||
ctrl.moveMediaItem(von, nach)
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun toggleRepeat() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.repeatMode = when (ctrl.repeatMode) {
|
||||
@@ -155,22 +262,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val ctrl = controller ?: return
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
|
||||
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
|
||||
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
|
||||
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
|
||||
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
|
||||
val neuerRest = if (!isManuallyShuffled) {
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
queue = played + remaining
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
queue.drop(currentIdx + 1).shuffled()
|
||||
} else {
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
originalQueue.drop(origIdx + 1)
|
||||
}
|
||||
queue = queue.take(currentIdx + 1) + neuerRest
|
||||
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -207,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
|
||||
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
|
||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||
.filter { it.id != currentSong.id }
|
||||
}
|
||||
|
||||
val ctrl = controller ?: return
|
||||
@@ -227,11 +337,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
val ctrl = controller ?: break
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition) }
|
||||
// Refresh durationMs too — after a SEAMLESS track transition (skipping through
|
||||
// already-buffered tracks) the player stays READY, so onPlaybackStateChanged never
|
||||
// fires and syncState() isn't re-called; ctrl.duration was TIME_UNSET(→0) at the
|
||||
// transition, leaving durationMs stuck at 0. Seek then computes fraction*0 = 0 and
|
||||
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Player duration, falling back to the current song's known duration while ctrl.duration is
|
||||
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
|
||||
private fun resolveDurationMs(ctrl: MediaController): Long {
|
||||
val d = ctrl.duration
|
||||
if (d > 0) return d
|
||||
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
|
||||
return songDurSecs * 1000L
|
||||
}
|
||||
|
||||
private fun stopTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = null
|
||||
@@ -245,6 +369,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||
delay(delayMs)
|
||||
runCatching { repo.scrobble(song.id) }
|
||||
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +395,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
currentSong = queue.getOrNull(idx),
|
||||
currentIndex = idx,
|
||||
positionMs = ctrl.currentPosition,
|
||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
||||
durationMs = resolveDurationMs(ctrl),
|
||||
queue = queue,
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
@@ -302,6 +427,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
syncState()
|
||||
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
|
||||
if (!isPlaying) persistQueueState()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||
@@ -314,9 +441,14 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
||||
if (mediaId.startsWith("abs:")) return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
val dur = ctrl.duration.coerceAtLeast(0)
|
||||
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
|
||||
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
|
||||
// far too early. Fall back to the player only if the song has no duration.
|
||||
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
|
||||
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||
persistQueueState()
|
||||
prefetchLyrics(song)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
@@ -78,6 +80,29 @@ fun AppNavigation() {
|
||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
||||
|
||||
// ── In-app updater — offer the newer APK the backend advertises ─────────────
|
||||
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
|
||||
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
|
||||
val updateScope = rememberCoroutineScope()
|
||||
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
|
||||
updateInfo?.let { info ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
|
||||
title = { Text("Update verfügbar") },
|
||||
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
|
||||
confirmButton = {
|
||||
TextButton(enabled = !updating, onClick = {
|
||||
updateScope.launch {
|
||||
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
|
||||
)
|
||||
}
|
||||
|
||||
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
|
||||
// cancels any active preview so the two don't overlap.
|
||||
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
||||
@@ -98,11 +123,37 @@ fun AppNavigation() {
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen,
|
||||
// bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich
|
||||
// verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still.
|
||||
LaunchedEffect(Unit) {
|
||||
com.novasonic.app.data.DeepLinkState.pending.collect { uri ->
|
||||
if (uri == null) return@collect
|
||||
com.novasonic.app.data.DeepLinkState.consume()
|
||||
val songId = uri.getQueryParameter("song")
|
||||
val albumId = uri.getQueryParameter("album")
|
||||
runCatching {
|
||||
when {
|
||||
songId != null -> {
|
||||
val song = repo.getSong(songId)
|
||||
song.albumId?.let { navController.navigate("album/$it") }
|
||||
player.playSongs(listOf(song), 0)
|
||||
}
|
||||
albumId != null -> navController.navigate("album/$albumId")
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
var showNowPlaying by remember { mutableStateOf(false) }
|
||||
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
var songToDelete by remember { mutableStateOf<Song?>(null) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
||||
var showAbsPlayer by remember { mutableStateOf(false) }
|
||||
@@ -112,7 +163,15 @@ fun AppNavigation() {
|
||||
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
DisposableEffect(showNowPlaying, backDispatcher) {
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
// Returning from the background re-registers the NavController's OWN back callback on top of the
|
||||
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
|
||||
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
|
||||
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
|
||||
var resumeTick by remember { mutableIntStateOf(0) }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
|
||||
|
||||
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||
@@ -120,7 +179,7 @@ fun AppNavigation() {
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
DisposableEffect(showAbsPlayer, backDispatcher) {
|
||||
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||
@@ -128,7 +187,7 @@ fun AppNavigation() {
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
DisposableEffect(selectedAbsItem, backDispatcher) {
|
||||
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||
@@ -148,7 +207,6 @@ fun AppNavigation() {
|
||||
|
||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
|
||||
@@ -156,7 +214,7 @@ fun AppNavigation() {
|
||||
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
|
||||
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
|
||||
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
|
||||
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry) {
|
||||
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
|
||||
val action = exploreOverlayBack
|
||||
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
@@ -318,7 +376,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onShowAllGenres = { navController.navigate("genres") },
|
||||
@@ -357,6 +415,19 @@ fun AppNavigation() {
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
onOpenNews = { navController.navigate("artist-news") },
|
||||
onOpenFollowed = {
|
||||
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
|
||||
navController.navigate(TopLevel.LIBRARY.route)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("artist-news") {
|
||||
ArtistNewsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ABS.route) {
|
||||
@@ -378,7 +449,28 @@ fun AppNavigation() {
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
|
||||
composable(TopLevel.SETTINGS.route) {
|
||||
SettingsScreen(
|
||||
onOpenStats = { navController.navigate("stats") },
|
||||
onOpenCompare = { navController.navigate("compare") },
|
||||
)
|
||||
}
|
||||
composable("stats") {
|
||||
StatsScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
composable("compare") {
|
||||
CompareScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"album/{albumId}",
|
||||
@@ -455,7 +547,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
@@ -529,6 +621,58 @@ fun AppNavigation() {
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
contextMenuSong = null
|
||||
},
|
||||
// Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet —
|
||||
// uebertragen wird nur die Song-Id, kein Geheimnis. Der Host kommt aus BuildConfig
|
||||
// (dieselbe Angabe wie fuer die App-Links im Manifest), damit es keine vierte
|
||||
// Adresse zum Pflegen gibt und beide Richtungen nie auseinanderlaufen koennen.
|
||||
onShare = {
|
||||
val link = "https://${com.novasonic.app.BuildConfig.SHARE_HOST}/?song=${song.id}"
|
||||
val text = listOfNotNull(song.title, song.artist).joinToString(" — ")
|
||||
context.startActivity(android.content.Intent.createChooser(
|
||||
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_SUBJECT, text)
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link")
|
||||
}, "Song teilen"))
|
||||
},
|
||||
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
|
||||
// kann keine Dateien entfernen.
|
||||
onDelete = if (appSettings.backendUrl.isNotBlank()) {
|
||||
{ songToDelete = song; contextMenuSong = null }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
|
||||
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
|
||||
songToDelete?.let { song ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { songToDelete = null },
|
||||
title = { Text("Song löschen") },
|
||||
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
|
||||
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
songToDelete = null
|
||||
scope.launch {
|
||||
val creds = repo.lastCredentials
|
||||
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
|
||||
val res = runCatching {
|
||||
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
|
||||
creds?.password ?: "", song.id)
|
||||
}
|
||||
client.close()
|
||||
Toast.makeText(
|
||||
context,
|
||||
res.fold({ "🗑 \"${song.title}\" gelöscht" },
|
||||
{ "Löschen fehlgeschlagen: ${it.message}" }),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
|
||||
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
|
||||
}
|
||||
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -576,13 +720,19 @@ fun AppNavigation() {
|
||||
val res = repo.search(n.navAlbum)
|
||||
val albums = res.album
|
||||
val norm = { s: String -> s.lowercase().trim() }
|
||||
val na = norm(n.navArtist)
|
||||
// REQUIRE the artist match when we know it — several artists share an
|
||||
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
|
||||
// opened a completely different artist's album (wrong song).
|
||||
val match = albums.firstOrNull {
|
||||
norm(it.name) == norm(n.navAlbum) &&
|
||||
(n.navArtist.isBlank() || norm(it.artist ?: "") == norm(n.navArtist))
|
||||
} ?: albums.firstOrNull { norm(it.name) == norm(n.navAlbum) }
|
||||
?: albums.firstOrNull()
|
||||
val aa = norm(it.artist ?: "")
|
||||
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
|
||||
(aa == na || aa.contains(na) || na.contains(aa))
|
||||
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
|
||||
if (match != null) {
|
||||
navController.navigate("album/${match.id}")
|
||||
} else {
|
||||
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
/**
|
||||
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
|
||||
*
|
||||
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
|
||||
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
|
||||
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
|
||||
*/
|
||||
fun formatGesamtdauer(sekunden: Int): String {
|
||||
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
|
||||
if (min < 60) return "$min Min."
|
||||
val std = min / 60
|
||||
val rest = min % 60
|
||||
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Geteilter Link, mit dem die App gestartet wurde.
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
@@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
|
||||
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
@@ -31,6 +32,7 @@ fun ConcertsSheet(
|
||||
artistName: String,
|
||||
events: List<ConcertEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
eventsError: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -47,17 +49,25 @@ fun ConcertsSheet(
|
||||
)
|
||||
|
||||
if (events.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||
) {
|
||||
Text(
|
||||
"Keine Konzerte angekündigt",
|
||||
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
if (eventsError != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
eventsError,
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -78,7 +88,7 @@ fun ConcertsSheet(
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
|
||||
val w = formatEventWhen(event.datetime)
|
||||
val venue = event.venue?.name.orEmpty()
|
||||
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
||||
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
||||
@@ -92,26 +102,33 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
// Date block
|
||||
// Date block — fixed width, calendar style (day big, month/year + time small) so every
|
||||
// row is the same height and the divider/venue line up.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(52.dp),
|
||||
modifier = Modifier.width(56.dp),
|
||||
) {
|
||||
Text(
|
||||
dateStr,
|
||||
w.day,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsAccent,
|
||||
fontSize = 18.sp, color = NsAccent, maxLines = 1,
|
||||
)
|
||||
if (timeStr.isNotBlank()) {
|
||||
if (w.monthYear.isNotBlank()) {
|
||||
Text(
|
||||
timeStr,
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
w.monthYear,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (w.time.isNotBlank()) {
|
||||
Text(
|
||||
w.time,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
|
||||
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
// Venue
|
||||
@@ -156,14 +173,21 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatEventDateTime(datetime: String): Pair<String, String> {
|
||||
private data class EventWhen(val day: String, val monthYear: String, val time: String)
|
||||
|
||||
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
|
||||
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
|
||||
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
private fun formatEventWhen(datetime: String): EventWhen {
|
||||
return try {
|
||||
val zdt = ZonedDateTime.parse(datetime)
|
||||
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
|
||||
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
|
||||
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
date to time
|
||||
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
|
||||
// values ("…Z") still parse via the ZonedDateTime fallback.
|
||||
val ldt = runCatching { LocalDateTime.parse(datetime) }
|
||||
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
|
||||
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
|
||||
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
|
||||
} catch (_: Exception) {
|
||||
datetime.take(10) to ""
|
||||
EventWhen(datetime.take(10), "", "")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -26,6 +25,8 @@ import com.novasonic.app.ui.theme.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.novasonic.app.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -49,7 +50,7 @@ fun NotificationSheet(
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Notifications, null,
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
|
||||
tint = NsAccent, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
|
||||
*
|
||||
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
|
||||
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
|
||||
*
|
||||
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
|
||||
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
|
||||
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
|
||||
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
|
||||
*
|
||||
* Drei Fallen steckten hier drin, alle beim Ziehen:
|
||||
*
|
||||
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
|
||||
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
|
||||
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
|
||||
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
|
||||
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
|
||||
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
|
||||
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
|
||||
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
|
||||
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
|
||||
* Laufzeit nach.
|
||||
*
|
||||
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
|
||||
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
|
||||
*
|
||||
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
|
||||
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
|
||||
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
|
||||
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
|
||||
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
|
||||
*/
|
||||
@Composable
|
||||
fun Rollbalken(
|
||||
state: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
breite: Dp = 6.dp,
|
||||
griffBreite: Dp = 24.dp,
|
||||
minHoehe: Dp = 44.dp,
|
||||
farbe: Color = Color.White,
|
||||
) {
|
||||
val dichte = LocalDensity.current
|
||||
var spurPx by remember { mutableFloatStateOf(0f) }
|
||||
var ziehen by remember { mutableStateOf(false) }
|
||||
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
|
||||
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
|
||||
// bliebe bei langsamem Ziehen stehen.
|
||||
var zielPos by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val info = state.layoutInfo
|
||||
val gesamt = info.totalItemsCount
|
||||
val zuSehen = info.visibleItemsInfo.size
|
||||
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
|
||||
|
||||
val aktiv = state.isScrollInProgress || ziehen
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (aktiv) 0.9f else 0.3f,
|
||||
animationSpec = tween(if (aktiv) 120 else 450),
|
||||
label = "rollbalken-alpha",
|
||||
)
|
||||
|
||||
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||
val anteil = if (ziehen) zielPos / maxIndex
|
||||
else state.firstVisibleItemIndex.toFloat() / maxIndex
|
||||
val daumenY = frei * anteil.coerceIn(0f, 1f)
|
||||
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
|
||||
|
||||
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
|
||||
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
|
||||
|
||||
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
|
||||
LaunchedEffect(ziehen) {
|
||||
if (!ziehen) return@LaunchedEffect
|
||||
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(griffBreite)
|
||||
.fillMaxHeight()
|
||||
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||
) {
|
||||
val luftDp = with(dichte) { spielPx.toDp() }
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
|
||||
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
|
||||
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
|
||||
.width(griffBreite)
|
||||
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val start = awaitFirstDown(requireUnconsumed = false)
|
||||
if (geo.value.frei <= 0f) return@awaitEachGesture
|
||||
start.consume()
|
||||
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||
ziehen = true
|
||||
try {
|
||||
while (true) {
|
||||
val ereignis = awaitPointerEvent()
|
||||
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
|
||||
if (!punkt.pressed) break
|
||||
val delta = punkt.positionChange().y
|
||||
punkt.consume()
|
||||
if (delta != 0f) {
|
||||
val jetzt = geo.value
|
||||
if (jetzt.frei > 0f) {
|
||||
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
|
||||
.coerceIn(0f, jetzt.maxIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ziehen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(vertical = luftDp, horizontal = 0.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(end = 2.dp)
|
||||
.width(breite)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(farbe.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
|
||||
private data class Geometrie(
|
||||
val maxIndex: Float,
|
||||
val frei: Float,
|
||||
val daumenY: Float,
|
||||
val daumenPx: Float,
|
||||
val spiel: Float,
|
||||
)
|
||||
@@ -33,6 +33,8 @@ fun SongContextSheet(
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onShare: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -77,6 +79,16 @@ fun SongContextSheet(
|
||||
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
||||
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||
onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) }
|
||||
}
|
||||
|
||||
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
|
||||
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
|
||||
onDelete?.let {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { onDismiss(); it() })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
@@ -45,7 +45,10 @@ fun SongRow(
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay)
|
||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp),
|
||||
// Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
|
||||
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
|
||||
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
|
||||
) {
|
||||
// Accent left bar when playing
|
||||
if (isPlaying) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -11,6 +12,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
@@ -34,6 +38,8 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
import com.novasonic.app.ui.formatGesamtdauer
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
@@ -72,7 +78,9 @@ fun AlbumScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -141,6 +149,7 @@ fun AlbumScreen(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
@@ -152,7 +161,12 @@ fun AlbumScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
// ── Play / Shuffle / Merken / Download ────────────────────────────
|
||||
// Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe.
|
||||
// Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest
|
||||
// 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf
|
||||
// schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen
|
||||
// sind umrandet und faerben sich erst accent, wenn sie aktiv sind.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -162,20 +176,37 @@ fun AlbumScreen(
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
|
||||
// Kuenstler-IDs beim Markieren gleich.
|
||||
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||
val gemerkt = overrides[album.id] ?: (album.starred != null)
|
||||
OutlinedButton(
|
||||
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (gemerkt) "Gemerkt" else "Album merken",
|
||||
Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
@@ -183,22 +214,17 @@ fun AlbumScreen(
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(
|
||||
when {
|
||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
else -> NsAccent.copy(alpha = 0.55f)
|
||||
}
|
||||
)
|
||||
brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,4 +246,10 @@ fun AlbumScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
@@ -51,6 +54,7 @@ import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
@@ -66,6 +70,7 @@ fun ArtistDetailScreen(
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||
@@ -80,7 +85,7 @@ fun ArtistDetailScreen(
|
||||
|
||||
LaunchedEffect(state.pendingShufflePlay) {
|
||||
state.pendingShufflePlay?.let { songs ->
|
||||
onPlay(songs, 0)
|
||||
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
|
||||
vm.consumeShufflePlay()
|
||||
}
|
||||
}
|
||||
@@ -93,6 +98,7 @@ fun ArtistDetailScreen(
|
||||
artistName = artist.name,
|
||||
events = state.events,
|
||||
onDismiss = vm::dismissEventsSheet,
|
||||
eventsError = state.eventsError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +117,22 @@ fun ArtistDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Artist-pin picker
|
||||
if (state.showPinPicker) {
|
||||
ArtistPinSheet(
|
||||
artistName = state.artist?.name ?: "",
|
||||
pinned = state.pin,
|
||||
searching = state.pinSearching,
|
||||
candidates = state.pinCandidates,
|
||||
onPick = vm::pinCandidate,
|
||||
onPinLink = { link -> vm.pinByLink(link) { ok ->
|
||||
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
|
||||
} },
|
||||
onClear = vm::clearPin,
|
||||
onDismiss = vm::dismissPinPicker,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
@@ -137,6 +159,7 @@ fun ArtistDetailScreen(
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -195,11 +218,14 @@ fun ArtistDetailScreen(
|
||||
title = openAllAlbums ?: "",
|
||||
entries = allAlbumEntries,
|
||||
onBack = { openAllAlbums = null },
|
||||
gridState = vm.allAlbumsGrid,
|
||||
query = vm.allAlbumsQuery.value,
|
||||
onQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
Row(
|
||||
@@ -218,10 +244,10 @@ fun ArtistDetailScreen(
|
||||
|
||||
// Hero card
|
||||
item {
|
||||
// Navidrome's own artist image first; if its agents have none (niche artists like N!GHT),
|
||||
// fall back to the matched Deezer artist photo, then the album cover.
|
||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: state.deezerImage
|
||||
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||
// Navidrome/Last.fm image is the wrong person).
|
||||
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: coverUrl(artist.coverArt)
|
||||
Box(
|
||||
Modifier
|
||||
@@ -237,6 +263,38 @@ fun ArtistDetailScreen(
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
|
||||
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
|
||||
// direkt neben das Zahnrad — genau wie im Web.
|
||||
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||
val folgt = overrides[artist.id] ?: (artist.starred != null)
|
||||
Surface(
|
||||
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0x59000000),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
|
||||
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
|
||||
modifier = Modifier.padding(7.dp).size(17.dp),
|
||||
)
|
||||
}
|
||||
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
|
||||
Surface(
|
||||
onClick = { vm.openPinPicker() },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0x59000000),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚙",
|
||||
fontSize = 14.sp,
|
||||
color = if (state.pin != null) Color(0xFF7EB8FF) else Color.White.copy(0.72f),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
@@ -293,11 +351,52 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Similar artists (exclude the current artist)
|
||||
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
|
||||
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
|
||||
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
|
||||
val similarArtists = state.info?.similarArtist
|
||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||
?: emptyList()
|
||||
if (similarArtists.isNotEmpty()) {
|
||||
if (usePinnedRelated) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
|
||||
// if the artist is in the library, else say so.
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val id = vm.libraryArtistId(rel.artistName)
|
||||
if (id != null && onArtistClick != null) onArtistClick(id)
|
||||
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.artworkUrl100,
|
||||
contentDescription = rel.artistName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (similarArtists.isNotEmpty()) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
@@ -549,8 +648,10 @@ internal fun AllAlbumsOverlay(
|
||||
title: String,
|
||||
entries: List<AllAlbumEntry>,
|
||||
onBack: () -> Unit,
|
||||
gridState: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
|
||||
query: String = "",
|
||||
onQueryChange: (String) -> Unit = {},
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(query, entries) {
|
||||
if (query.isBlank()) entries
|
||||
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
||||
@@ -585,11 +686,11 @@ internal fun AllAlbumsOverlay(
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) { {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
@@ -609,6 +710,7 @@ internal fun AllAlbumsOverlay(
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -632,3 +734,93 @@ internal fun AllAlbumsOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Artist-pin picker ────────────────────────────────────────────────────────
|
||||
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
|
||||
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ArtistPinSheet(
|
||||
artistName: String,
|
||||
pinned: com.novasonic.app.api.model.ArtistOverride?,
|
||||
searching: Boolean,
|
||||
candidates: List<com.novasonic.app.api.model.ItunesItem>,
|
||||
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
|
||||
onPinLink: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var link by remember { mutableStateOf("") }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface)
|
||||
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
|
||||
|
||||
if (pinned != null) {
|
||||
OutlinedButton(
|
||||
onClick = onClear,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
|
||||
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = link, onValueChange = { link = it },
|
||||
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
|
||||
singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { if (link.isNotBlank()) onPinLink(link) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
|
||||
|
||||
when {
|
||||
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
|
||||
}
|
||||
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
|
||||
else -> Column(Modifier.fillMaxWidth()) {
|
||||
candidates.forEach { c ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
|
||||
) {
|
||||
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
|
||||
AsyncImage(model = c.artworkUrl100, contentDescription = null,
|
||||
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import com.novasonic.app.viewmodel.NewsReiter
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistNewsScreen(
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val vm: ArtistNewsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||
Text(
|
||||
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||
// hinter, Konzerte vor dem heutigen Tag.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
) {
|
||||
listOf(
|
||||
NewsReiter.RELEASES to "Neu erschienen",
|
||||
NewsReiter.KONZERTE to "Konzerte",
|
||||
).forEach { (r, label) ->
|
||||
FilterChip(
|
||||
selected = state.reiter == r,
|
||||
onClick = { vm.zeigeReiter(r) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
|
||||
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
if (state.reiter == NewsReiter.RELEASES) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||
) {
|
||||
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.setzeTage(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
|
||||
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
|
||||
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||
"Keine anstehenden Termine",
|
||||
"Gesucht wird weltweit über den Künstlernamen.")
|
||||
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
var letzterMonat = ""
|
||||
state.konzerte.forEach { k ->
|
||||
val monat = monatVon(k.datum)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "km_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
if (state.ohneZuordnung.isNotEmpty()) {
|
||||
item { ZuordnungsHinweis(state.ohneZuordnung) }
|
||||
}
|
||||
var letzterMonat = ""
|
||||
state.releases.forEach { rel ->
|
||||
val monat = monatVon(rel.releaseDate)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "m_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "${rel.artistId}_${rel.albumId}") {
|
||||
ReleaseZeile(
|
||||
rel = rel,
|
||||
schonDa = vm.hatSchon(rel),
|
||||
onArtist = { onArtistClick(rel.artistId) },
|
||||
onDownload = {
|
||||
vm.herunterladen(rel) { _, msg ->
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hinweis(symbol: String, titel: String, text: String) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(symbol, fontSize = 34.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (text.isNotBlank()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZuordnungsHinweis(namen: List<String>) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent.copy(alpha = 0.08f))
|
||||
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
|
||||
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
|
||||
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
|
||||
"compile" to "Compilation")
|
||||
|
||||
@Composable
|
||||
private fun ReleaseZeile(
|
||||
rel: ArtistRelease,
|
||||
schonDa: Boolean,
|
||||
onArtist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.cover.takeIf { it.isNotBlank() },
|
||||
contentDescription = rel.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
Text(
|
||||
" · " + (TYP[rel.recordType] ?: rel.recordType) +
|
||||
(rel.trackCount?.let { " · $it Titel" } ?: ""),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (schonDa) {
|
||||
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
|
||||
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer liefert "JJJJ-MM-TT".
|
||||
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
|
||||
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
|
||||
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
|
||||
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||
|
||||
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||
*/
|
||||
@Composable
|
||||
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val d = zeitVon(k.datum)
|
||||
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||
) {
|
||||
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||
fontSize = 10.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
k.titel.ifBlank { k.artistName },
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||
if (rest.isNotEmpty()) {
|
||||
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k.abgesagt) {
|
||||
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
} else if (k.url.isNotBlank()) {
|
||||
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.CompareResult
|
||||
import com.novasonic.app.api.model.SharedArtist
|
||||
import com.novasonic.app.api.model.SharedTrack
|
||||
import com.novasonic.app.api.model.UniqueArtist
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.CompareViewModel
|
||||
|
||||
@Composable
|
||||
fun CompareScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: CompareViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Musik-Match",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||
color = NsOnSurface, modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Show "wechseln" once a partner is picked so you can go back to the list.
|
||||
if (state.partner != null) {
|
||||
Text(
|
||||
"↺ Wechseln",
|
||||
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { vm.clearPartner() }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
!state.configured -> CenterMsg(
|
||||
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||
)
|
||||
state.error != null -> CenterMsg(state.error!!, NsError)
|
||||
state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner)
|
||||
state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
else -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PartnerPicker(
|
||||
me: String,
|
||||
users: List<com.novasonic.app.api.model.ScrobbleUser>,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
if (users.isEmpty()) {
|
||||
CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.")
|
||||
return
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
"Mit wem willst du dich vergleichen?",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
)
|
||||
users.forEach { u ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(vertical = 5.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPick(u.user) }
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
(u.user.firstOrNull()?.uppercase() ?: "?"),
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareContent(
|
||||
d: CompareResult,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
Scoreboard(d)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Totals(d)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
|
||||
SectionHeader("Gemeinsame Künstler")
|
||||
if (d.sharedArtists.isEmpty()) {
|
||||
EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.")
|
||||
} else {
|
||||
val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1)
|
||||
d.sharedArtists.take(15).forEach { a ->
|
||||
SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
|
||||
if (d.sharedTracks.isNotEmpty()) {
|
||||
SectionHeader("Gemeinsame Songs")
|
||||
d.sharedTracks.take(12).forEach { t ->
|
||||
SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId),
|
||||
t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
|
||||
Streitpunkte(d, onArtistClick)
|
||||
|
||||
SectionHeader("Nur bei…")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
|
||||
UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
|
||||
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
|
||||
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
|
||||
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
|
||||
val items = d.sharedArtists
|
||||
.map { a ->
|
||||
val summe = a.playsA + a.playsB
|
||||
Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
|
||||
}
|
||||
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
|
||||
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
|
||||
.take(8)
|
||||
|
||||
SectionHeader("Streitpunkte")
|
||||
if (items.isEmpty()) {
|
||||
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||
return
|
||||
}
|
||||
items.forEach { (a, summe, _) ->
|
||||
val anteilA = a.playsA / summe.toFloat()
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable { a.artistId?.let(onArtistClick) }
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
fontWeight = FontWeight.Bold)
|
||||
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
|
||||
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
|
||||
Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
|
||||
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
|
||||
.background(NsAccent.copy(alpha = .35f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scoreboard(d: CompareResult) {
|
||||
val b = d.battle
|
||||
if (b == null || b.kategorien.isEmpty()) return
|
||||
val urteil = when {
|
||||
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
|
||||
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
|
||||
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
|
||||
fontSize = 30.sp, color = NsOnSurface)
|
||||
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
}
|
||||
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
|
||||
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
|
||||
b.kategorien.forEach { k ->
|
||||
val gewinnA = k.sieger == "A"
|
||||
val gewinnB = k.sieger == "B"
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
|
||||
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
|
||||
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||
maxLines = 1, modifier = Modifier.weight(1.3f))
|
||||
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
|
||||
modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Totals(d: CompareResult) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f))
|
||||
TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) {
|
||||
Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) {
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 7.dp),
|
||||
) {
|
||||
Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp))
|
||||
// left (A) bar grows toward the centre
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)).background(NsAccent))
|
||||
}
|
||||
Text(
|
||||
a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp),
|
||||
)
|
||||
// right (B) bar grows from the centre
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f)))
|
||||
}
|
||||
Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) {
|
||||
AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!t.artist.isNullOrBlank()) {
|
||||
Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UniqueColumn(title: String, items: List<UniqueArtist>, onArtistClick: (String) -> Unit, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 6.dp))
|
||||
if (items.isEmpty()) {
|
||||
Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp))
|
||||
} else {
|
||||
items.take(6).forEach { a ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(t: String) {
|
||||
Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLine(msg: String) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMsg(msg: String, color: Color = NsOnSurface2) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.novasonic.app.ui.screen
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -113,6 +115,7 @@ fun ExploreScreen(
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
}
|
||||
albumTracks != null && albumItem != null -> {
|
||||
@@ -123,6 +126,7 @@ fun ExploreScreen(
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
}
|
||||
@@ -142,28 +146,44 @@ fun ExploreScreen(
|
||||
allAlbumsOpen = state.artistAllAlbums,
|
||||
onOpenAllAlbums = vm::openArtistAllAlbums,
|
||||
onCloseAllAlbums = vm::closeArtistAllAlbums,
|
||||
allAlbumsGrid = vm.allAlbumsGrid,
|
||||
allAlbumsQuery = vm.allAlbumsQuery.value,
|
||||
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
|
||||
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = ExploreTab.values()[pagerState.settledPage]
|
||||
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
|
||||
// half-way point), not only once the swipe settles — that lag read as "not smooth".
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.LIBRARY,
|
||||
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||
text = { Text("Bibliothek", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.GLOBAL,
|
||||
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||
text = { Text("Erkunden", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
@@ -230,9 +250,19 @@ fun ExploreScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────
|
||||
when (state.tab) {
|
||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
||||
// ── Content (swipeable) ────────────────────────────────────────────────
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
|
||||
// builds on first drag.
|
||||
beyondViewportPageCount = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { page ->
|
||||
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
|
||||
// vertically centered in the page.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (page) {
|
||||
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
|
||||
query = state.query,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
@@ -241,7 +271,7 @@ fun ExploreScreen(
|
||||
onArtistClick = onArtistClick,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
||||
else -> GlobalExploreContent(
|
||||
state = state,
|
||||
vm = vm,
|
||||
coverUrl = coverUrl,
|
||||
@@ -252,6 +282,8 @@ fun ExploreScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library (local Navidrome) search ─────────────────────────────────────────
|
||||
@@ -289,7 +321,7 @@ private fun LibrarySearchContent(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
LazyColumn(state = vm.listState) {
|
||||
val artists = state.searchResult?.artist ?: emptyList()
|
||||
val albums = state.searchResult?.album ?: emptyList()
|
||||
val songs = state.searchResult?.song ?: emptyList()
|
||||
@@ -310,9 +342,11 @@ private fun LibrarySearchContent(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(artists, key = { it.id }) { a ->
|
||||
// search3 artist results usually omit coverArt → fall back to the artist
|
||||
// id, which Navidrome's getCoverArt accepts and resolves to the artist image.
|
||||
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
||||
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
|
||||
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
|
||||
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
|
||||
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
|
||||
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -374,6 +408,7 @@ private fun GlobalExploreContent(
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = onDownload,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
return
|
||||
@@ -431,7 +466,7 @@ private fun GlobalExploreContent(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
LazyColumn(state = vm.searchListState) {
|
||||
if (state.error != null) {
|
||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)) }
|
||||
@@ -491,6 +526,7 @@ internal fun ItunesAlbumDetail(
|
||||
previewingId: Long?,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
onArtistClick: ((ItunesItem) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -502,9 +538,25 @@ internal fun ItunesAlbumDetail(
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
// Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
|
||||
// lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
|
||||
val artistName = albumItem?.artistName
|
||||
val artistTippbar = onArtistClick != null && albumItem?.artistId != null &&
|
||||
!artistName.isNullOrBlank()
|
||||
Column(
|
||||
Modifier.weight(1f).then(
|
||||
if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier
|
||||
)
|
||||
) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (!artistName.isNullOrBlank()) {
|
||||
Text(artistName, fontFamily = Outfit, fontSize = 12.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (artistTippbar) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
if (albumItem != null) {
|
||||
IconButton(onClick = { onDownload(albumItem) }) {
|
||||
Icon(Icons.Default.Download, "Album herunterladen",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -18,6 +19,7 @@ import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun GenreSongsScreen(
|
||||
@@ -77,7 +79,10 @@ fun GenreSongsScreen(
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn {
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
@@ -91,6 +96,13 @@ fun GenreSongsScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
@@ -33,6 +32,8 @@ import com.novasonic.app.data.NotificationStore
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.HomeViewModel
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.novasonic.app.R
|
||||
|
||||
private val greetingsWithName = listOf(
|
||||
"Ready for some beats,",
|
||||
@@ -120,7 +121,7 @@ fun HomeScreen(
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onBellClick) {
|
||||
Icon(Icons.Default.Notifications, "Benachrichtigungen",
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
|
||||
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -180,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
|
||||
// ein einzelner Track ergibt keine Playlist.
|
||||
if (r.tracks.size > 1) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.createPlaylist,
|
||||
onCheckedChange = vm::setCreatePlaylist,
|
||||
colors = CheckboxDefaults.colors(checkedColor = brand,
|
||||
checkmarkColor = Color.Black),
|
||||
)
|
||||
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface)
|
||||
}
|
||||
if (state.createPlaylist) {
|
||||
OutlinedTextField(
|
||||
value = state.playlistName.ifBlank { r.name ?: "" },
|
||||
onValueChange = vm::setPlaylistName,
|
||||
label = { Text("Playlist-Name", fontFamily = Outfit) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
state.playlistStatus?.let {
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
@@ -205,7 +239,12 @@ fun ImporterScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
itemsIndexed(r.tracks) { idx, track ->
|
||||
ImportTrackRow(idx + 1, track)
|
||||
ImportTrackRow(
|
||||
num = idx + 1,
|
||||
track = track,
|
||||
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +297,12 @@ private fun JobProgress(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
private fun ImportTrackRow(
|
||||
num: Int,
|
||||
track: ImportTrack,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
@@ -272,11 +316,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!track.artistName.isNullOrBlank()) {
|
||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
// 30s preview — only when the source provided a clip
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||
|
||||
private fun normAlbum(s: String?): String =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
|
||||
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
data class ArtistAlbumCategories(
|
||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||
@@ -68,6 +70,10 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
||||
}
|
||||
when {
|
||||
match == null -> notDl.add(ia)
|
||||
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
|
||||
// above only catches identical ids, so also skip an owned album we've already classified —
|
||||
// otherwise it shows 2-3x ("PIXELATED KISSES").
|
||||
match.id in usedNdIds -> Unit
|
||||
iCount > match.songCount -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
}
|
||||
@@ -103,6 +109,9 @@ fun ItunesArtistScreen(
|
||||
allAlbumsOpen: String?,
|
||||
onOpenAllAlbums: (String) -> Unit,
|
||||
onCloseAllAlbums: () -> Unit,
|
||||
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
|
||||
allAlbumsQuery: String = "",
|
||||
onAllAlbumsQueryChange: (String) -> Unit = {},
|
||||
) {
|
||||
val artist = artistState.artist
|
||||
val cats = if (!artistState.isLoading)
|
||||
@@ -138,6 +147,9 @@ fun ItunesArtistScreen(
|
||||
title = allAlbumsOpen,
|
||||
entries = allAlbumEntries,
|
||||
onBack = onCloseAllAlbums,
|
||||
gridState = allAlbumsGrid,
|
||||
query = allAlbumsQuery,
|
||||
onQueryChange = onAllAlbumsQueryChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -213,7 +225,7 @@ fun ItunesArtistScreen(
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
||||
onClick = { onSongPlay(artistState.topSongs, -1) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.border
|
||||
@@ -64,6 +64,15 @@ fun LibraryScreen(
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val offlineVm: OfflineViewModel = viewModel()
|
||||
|
||||
// Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen.
|
||||
LaunchedEffect(Unit) {
|
||||
if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) {
|
||||
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false
|
||||
vm.selectTab(LibraryTab.ARTISTS)
|
||||
vm.nurGefolgteUmschalten(true)
|
||||
}
|
||||
}
|
||||
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||
|
||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||
@@ -79,16 +88,28 @@ fun LibraryScreen(
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||
|
||||
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
|
||||
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
|
||||
// pager's current page so it moves WITH the swipe.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = LibraryTab.entries[pagerState.settledPage]
|
||||
if (t != state.tab) vm.selectTab(t)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
LibraryTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.tab == tab,
|
||||
selected = pagerState.currentPage == tab.ordinal,
|
||||
onClick = { vm.selectTab(tab) },
|
||||
text = {
|
||||
Text(
|
||||
@@ -108,16 +129,15 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
|
||||
// the online library is still loading or unreachable — only gate the network-backed tabs.
|
||||
if (state.isLoading && state.tab != LibraryTab.OFFLINE) {
|
||||
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (state.tab) {
|
||||
} else when (pageTab) {
|
||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||
// Sort chips
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
@@ -153,8 +173,10 @@ fun LibraryScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val albumGridState = rememberLazyGridState()
|
||||
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
|
||||
// Kein LaunchedEffect(state.albumSort) zum Hochscrollen: der liefe auch beim ersten
|
||||
// Compose nach dem Zurückkommen und würde die gemerkte Position sofort verwerfen.
|
||||
// Das Hochscrollen passiert jetzt in vm.setAlbumSort(), also nur bei echtem Wechsel.
|
||||
LazyVerticalGrid(
|
||||
state = albumGridState,
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
@@ -176,14 +198,22 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
LibraryTab.ARTISTS -> {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||
val scope = rememberCoroutineScope()
|
||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
|
||||
// die schon vier Kacheln trägt, nur Gedränge.
|
||||
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
|
||||
if (!state.nurGefolgte) state.artistIndexes
|
||||
else state.artistIndexes
|
||||
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
|
||||
.filter { it.artist.isNotEmpty() }
|
||||
}
|
||||
val letters = remember(indizes) { indizes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(state.artistIndexes) {
|
||||
val letterToIndex = remember(indizes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
@@ -191,13 +221,54 @@ fun LibraryScreen(
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(false) },
|
||||
label = { Text("Alle", fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(true) },
|
||||
label = {
|
||||
val n = state.gefolgteIds.size
|
||||
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
|
||||
fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
}
|
||||
if (state.nurGefolgte && indizes.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤", fontSize = 30.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Du folgst noch niemandem", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
@@ -305,6 +376,7 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
@@ -314,9 +386,11 @@ fun LibraryScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.playlists, key = { it.id }) { pl ->
|
||||
// Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen.
|
||||
val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
downloaded = pl.id in downloadedPlaylistIds,
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
@@ -388,9 +462,10 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end HorizontalPager
|
||||
} // end Column
|
||||
|
||||
if (state.tab == LibraryTab.PLAYLISTS) {
|
||||
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier
|
||||
|
||||
@@ -24,7 +24,8 @@ fun LoginScreen(onLoggedIn: () -> Unit) {
|
||||
val vm: LoginViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
// Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
|
||||
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -58,9 +60,35 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LyricsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
|
||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||
|
||||
// ── Obergrenzen fuer grosse Anzeigen (Fold aufgeklappt, Tablet) ──────────────────────────────
|
||||
// Bis 1.26 hatte diese Ansicht keine Breitengrenze: das Cover war `fillMaxWidth` mal
|
||||
// `aspectRatio(1f)`. Auf einem Handy sind das knappe 360 dp und passt; auf dem inneren Display
|
||||
// eines Fold sind es ueber 800 dp — ein quadratisches Cover dieser Groesse schob Titel,
|
||||
// Fortschrittsbalken und Knoepfe aus dem Bild, und die ganze Seite wirkte ueberdimensional.
|
||||
// Die Web-Ansicht deckelt dasselbe seit immer bei 480 px (`.np-inner { max-width: 480px }`).
|
||||
// Zusaetzlich wird das Cover gegen die HOEHE gedeckelt: bei einem fast quadratischen Display ist
|
||||
// die Breite kein gutes Mass mehr fuer ein quadratisches Bild.
|
||||
private val NP_SPALTE_MAX = 480.dp // Player-Seite: Cover, Titel, Fortschritt, Knoepfe
|
||||
private val NP_LISTE_MAX = 560.dp // Liedtext und Warteschlange — Listen duerfen etwas breiter
|
||||
private val NP_COVER_MAX = 400.dp
|
||||
// Was Titel, Album, Kuenstler, Fortschrittsbalken, Zeiten, Knopfreihe und die Abstaende
|
||||
// dazwischen zusammen brauchen (gemessen an den gesetzten Groessen, mit etwas Luft). Das Cover
|
||||
// bekommt den Rest — so passt die Seite auch auf kurze Anzeigen, wo bisher die Knopfreihe
|
||||
// unten abgeschnitten wurde.
|
||||
private val NP_RESTBEDARF = 260.dp
|
||||
// Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat) schrumpfen Abstaende und Abspielknopf
|
||||
// zusaetzlich, der Rest braucht dann weniger — und wenn es trotzdem nicht reicht, laesst sich
|
||||
// die Seite rollen.
|
||||
private val NP_RESTBEDARF_KURZ = 200.dp
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
state: PlayerState,
|
||||
@@ -76,6 +104,23 @@ fun NowPlayingScreen(
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
|
||||
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
|
||||
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
|
||||
// den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
|
||||
// Faellen weiter, es geht also nie eine Ansicht verloren.
|
||||
val prefsCtx = LocalContext.current
|
||||
val wischTitel by produceState(initialValue = true, prefsCtx) {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
|
||||
LaunchedEffect(tab) {
|
||||
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = NpTab.entries[pagerState.settledPage]
|
||||
if (t != tab) tab = t
|
||||
}
|
||||
|
||||
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
LaunchedEffect(song?.id) {
|
||||
@@ -143,11 +188,22 @@ fun NowPlayingScreen(
|
||||
// Light scrim for readability
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Zwei Groessenfragen entscheiden das Layout, nicht ein Geraetename:
|
||||
// schmal — passt die Kopfzeile mit drei Reitern noch in voller Groesse? (Flip-Deckel)
|
||||
// kurz — bleibt nach Kopfzeile und Raendern ueberhaupt Platz? (Flip-Deckel, Querformat)
|
||||
// Die Schwelle liegt bewusst UNTER 360 dp: 360 ist noch ein gewoehnliches Handy und
|
||||
// soll die Kopfzeile unveraendert behalten. Erfasst werden damit der Deckel eines Fold
|
||||
// (~352 dp) und — ueber `kurz` — das Frontdisplay eines Flip, das bei aehnlicher Breite
|
||||
// nur ein Drittel der Hoehe hat und wo drei Reiter in voller Groesse nicht hinpassen.
|
||||
val schmal = maxWidth < 356.dp || maxHeight < 560.dp
|
||||
val kurz = maxHeight < 560.dp
|
||||
val obenPad = if (kurz) maxOf(statusBarTop + 4.dp, 20.dp) else topPad
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp))
|
||||
.padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp))
|
||||
.pointerInput(Unit) {
|
||||
var dragY = 0f
|
||||
detectVerticalDragGestures(
|
||||
@@ -161,9 +217,13 @@ fun NowPlayingScreen(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 28.dp),
|
||||
modifier = Modifier
|
||||
// Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display
|
||||
// ganz aussen und die Reiter einsam in der Mitte.
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (schmal) 8.dp else 20.dp)
|
||||
.padding(bottom = if (kurz) 10.dp else 28.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = onDismiss) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||
@@ -178,14 +238,17 @@ fun NowPlayingScreen(
|
||||
.padding(3.dp),
|
||||
) {
|
||||
NpTab.entries.forEach { t ->
|
||||
val sel = tab == t
|
||||
val sel = pagerState.currentPage == t.ordinal
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
||||
.clickable { tab = t }
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
.padding(
|
||||
horizontal = if (schmal) 9.dp else 18.dp,
|
||||
vertical = if (kurz) 6.dp else 9.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
@@ -193,7 +256,8 @@ fun NowPlayingScreen(
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
NpTab.QUEUE -> "Queue"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = if (schmal) 11.sp else 13.sp,
|
||||
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
@@ -210,21 +274,83 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = !wischTitel,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { page ->
|
||||
// Die ganze Player-Seite nimmt die Wischgeste an, nicht nur das Cover: sonst muesste
|
||||
// man genau das Bild treffen, und darunter liegt die halbe Seite. Auf Warteschlange
|
||||
// und Text liegt sie NICHT — dort wuerde sie mit dem Ziehen in der Liste streiten.
|
||||
var seitenDrag by remember { mutableStateOf(0f) }
|
||||
val wischbar = wischTitel && NpTab.entries[page] == NpTab.PLAYER
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (!wischbar) Modifier else Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (seitenDrag < -80) controller.skipNext()
|
||||
else if (seitenDrag > 80) controller.skipPrev()
|
||||
seitenDrag = 0f
|
||||
},
|
||||
onDragCancel = { seitenDrag = 0f },
|
||||
onHorizontalDrag = { _, d -> seitenDrag += d },
|
||||
)
|
||||
}
|
||||
),
|
||||
) {
|
||||
when (NpTab.entries[page]) {
|
||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.PLAYER) {
|
||||
NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
// Das Cover nimmt das KLEINSTE aus drei Maszen: verfuegbare Breite (minus der
|
||||
// bisherigen 24 dp Rand je Seite), die Hoehe abzueglich dessen, was der Rest der
|
||||
// Seite braucht, und eine feste Obergrenze. Auf einem hohen Handy gewinnt die
|
||||
// Breite — dort bleibt alles wie bisher. Aufgeklappt greifen die anderen beiden.
|
||||
val coverKante = minOf(
|
||||
maxWidth - 48.dp,
|
||||
(maxHeight - if (kurz) NP_RESTBEDARF_KURZ else NP_RESTBEDARF).coerceAtLeast(120.dp),
|
||||
NP_COVER_MAX,
|
||||
)
|
||||
val spalte = minOf(maxWidth, NP_SPALTE_MAX)
|
||||
// Vor dem Rollbereich festhalten: innerhalb des Box-Lambdas ist `maxHeight` des
|
||||
// aeusseren Messrahmens nicht mehr erreichbar (Compose sperrt gleich markierte
|
||||
// Layout-Bereiche gegeneinander ab).
|
||||
val seitenHoehe = maxHeight
|
||||
// Greift ueberhaupt eine der neuen Grenzen? Nur dann weicht das Layout von dem
|
||||
// ab, was ein hohes Handy bisher zeigte. Solange die BREITE das engste Mass ist
|
||||
// — also auf jedem gewoehnlichen Handy —, bleibt es beim alten Aufbau: oben
|
||||
// beginnend mit 28 dp Abstand. Erst wenn Hoehe oder Obergrenze beissen (Fold
|
||||
// aufgeklappt, Tablet, Querformat), wird senkrecht zentriert; oben klebend mit
|
||||
// einem Loch darunter saehe dort falsch aus.
|
||||
val gedeckelt = coverKante < maxWidth - 48.dp || kurz
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Cover art — full content width, scale-animates like Apple Music
|
||||
// Passt es, wird es angeordnet wie oben beschrieben; passt es nicht, wird
|
||||
// gescrollt. Beides mit einem Aufbau: der Inhalt ist mindestens so hoch wie die
|
||||
// Seite, darf aber darueber hinauswachsen — dann hat der Rollbereich etwas zu
|
||||
// rollen. Auf dem Frontdisplay eines Flip bleibt sonst nichts uebrig, was ohne
|
||||
// Abschneiden darstellbar waere. Passt alles, ist der Rollweg null: dann
|
||||
// verhaelt sich die Seite wie ohne Rollbereich.
|
||||
// Die Wischgeste zum Schliessen bleibt erhalten: ein Rollbereich ohne Weg
|
||||
// verbraucht die Geste nicht, sie erreicht also weiter die Seite darunter.
|
||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (gedeckelt) Arrangement.Center else Arrangement.Top,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = seitenHoehe),
|
||||
) {
|
||||
if (!gedeckelt) Spacer(Modifier.height(28.dp))
|
||||
// Cover art — gedeckelt, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = song?.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.size(coverKante)
|
||||
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
||||
.shadow(40.dp, RoundedCornerShape(20.dp))
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
@@ -242,10 +368,10 @@ fun NowPlayingScreen(
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(if (kurz) 12.dp else 24.dp))
|
||||
|
||||
// ── Song info ──────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) {
|
||||
Column(Modifier.width(spalte).padding(horizontal = 28.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -334,10 +460,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Spacer(Modifier.height(if (kurz) 10.dp else 20.dp))
|
||||
|
||||
// ── Seek bar ───────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
|
||||
Column(Modifier.width(spalte).padding(horizontal = 24.dp)) {
|
||||
val progress = if (state.durationMs > 0)
|
||||
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
||||
|
||||
@@ -354,15 +480,15 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(if (kurz) 4.dp else 12.dp))
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
modifier = Modifier.width(spalte).padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||
}
|
||||
@@ -374,7 +500,7 @@ fun NowPlayingScreen(
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(68.dp)
|
||||
.size(if (kurz) 56.dp else 68.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { controller.togglePlay() },
|
||||
@@ -388,7 +514,8 @@ fun NowPlayingScreen(
|
||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
||||
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
|
||||
val (icon, tint) = when (state.repeatMode) {
|
||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||
@@ -397,15 +524,23 @@ fun NowPlayingScreen(
|
||||
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
|
||||
} // end Column (gedeckelte Player-Spalte)
|
||||
} // end Box (Rollbereich, falls die Seite nicht reicht)
|
||||
}
|
||||
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
NpTab.LYRICS -> if (song != null) {
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val lyricsListState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
|
||||
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
|
||||
val folgtMit by produceState(initialValue = false, prefsCtx) {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
|
||||
.collect { value = it.lyricsFolgenAutomatisch }
|
||||
}
|
||||
val folgenScope = rememberCoroutineScope()
|
||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||
val anchorFrac = 0.40f
|
||||
|
||||
@@ -416,7 +551,10 @@ fun NowPlayingScreen(
|
||||
|
||||
// Center the active line using its *measured* position — no cumulative drift, so
|
||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||
LaunchedEffect(activeIdx, lyricsViewH) {
|
||||
LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
|
||||
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
|
||||
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
|
||||
if (!folgtMit) return@LaunchedEffect
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
val info = lyricsListState.layoutInfo
|
||||
val anchor = info.viewportStartOffset +
|
||||
@@ -434,6 +572,42 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsState.lines.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
|
||||
)
|
||||
.clickable {
|
||||
folgenScope.launch {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx)
|
||||
.saveLyricsFolgen(!folgtMit)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 11.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown, null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
if (folgtMit) "Folgt" else "Mitlaufen",
|
||||
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
@@ -458,6 +632,8 @@ fun NowPlayingScreen(
|
||||
state = lyricsListState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr.
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { lyricsViewH = it.height },
|
||||
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
||||
@@ -478,7 +654,8 @@ fun NowPlayingScreen(
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f)
|
||||
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
) {
|
||||
item {
|
||||
@@ -495,12 +672,13 @@ fun NowPlayingScreen(
|
||||
}
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
if (tab == NpTab.QUEUE) {
|
||||
NpTab.QUEUE -> {
|
||||
// Radio mode toggle
|
||||
if (onToggleRadio != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
@@ -549,18 +727,39 @@ fun NowPlayingScreen(
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
} else {
|
||||
// ── Warteschlange bearbeiten ────────────────────────────
|
||||
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
|
||||
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
|
||||
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
|
||||
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
|
||||
// Reihenfolge sofort.
|
||||
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
|
||||
var ziehIdx by remember { mutableIntStateOf(-1) }
|
||||
var ziehOffset by remember { mutableFloatStateOf(0f) }
|
||||
val queueLaenge = rememberUpdatedState(state.queue.size)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f)
|
||||
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||
val active = state.currentIndex == idx
|
||||
val wirdGezogen = ziehIdx == idx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
|
||||
}
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
|
||||
.background(
|
||||
when {
|
||||
wirdGezogen -> Color.White.copy(0.16f)
|
||||
active -> Color.White.copy(0.08f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable { controller.seekToItem(idx) }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
@@ -588,12 +787,63 @@ fun NowPlayingScreen(
|
||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = { controller.entferneAusQueue(idx) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
|
||||
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.DragHandle, "Zum Verschieben ziehen",
|
||||
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
// Der erste Kontakt wird sofort verbraucht, sonst
|
||||
// beansprucht die LazyColumn die Geste, sobald die
|
||||
// Wischschwelle faellt.
|
||||
val start = awaitFirstDown(requireUnconsumed = false)
|
||||
start.consume()
|
||||
var aktuell = idx
|
||||
var rest = 0f
|
||||
ziehIdx = aktuell
|
||||
try {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
|
||||
if (!p.pressed) break
|
||||
rest += p.positionChange().y
|
||||
p.consume()
|
||||
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
|
||||
controller.verschiebeInQueue(aktuell, aktuell + 1)
|
||||
aktuell++; rest -= zeilePx
|
||||
}
|
||||
while (rest <= -zeilePx && aktuell > 0) {
|
||||
controller.verschiebeInQueue(aktuell, aktuell - 1)
|
||||
aktuell--; rest += zeilePx
|
||||
}
|
||||
ziehIdx = aktuell
|
||||
ziehOffset = rest
|
||||
}
|
||||
} finally {
|
||||
ziehIdx = -1; ziehOffset = 0f
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end when
|
||||
} // end inner Column
|
||||
} // end HorizontalPager
|
||||
}
|
||||
} // end BoxWithConstraints (schmal/kurz)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,11 +919,34 @@ private fun NpSeekBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
private fun NpCircleBtn(
|
||||
onClick: () -> Unit,
|
||||
aktiv: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
|
||||
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
|
||||
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
) { content() }
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
content()
|
||||
if (aktiv) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 5.dp)
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMs(ms: Long): String {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -31,11 +32,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.toSong
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun OfflineDetailScreen(
|
||||
@@ -62,8 +65,24 @@ fun OfflineDetailScreen(
|
||||
return
|
||||
}
|
||||
val songs = remember(tracks) { tracks.map { it.toSong() } }
|
||||
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
|
||||
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
|
||||
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
|
||||
val spielbar = remember(tracks) {
|
||||
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
|
||||
}
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
|
||||
val fehlend = songs.size - spielbar.size
|
||||
if (fehlend > 0) android.widget.Toast.makeText(
|
||||
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
onPlay(liste, i)
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -123,7 +142,7 @@ fun OfflineDetailScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) },
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -133,7 +152,7 @@ fun OfflineDetailScreen(
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -169,7 +188,15 @@ fun OfflineDetailScreen(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onPlay(songs, index) },
|
||||
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
|
||||
// Titel, die noch fehlen, die Indizes decken sich also nicht.
|
||||
onPlay = {
|
||||
val i = spielbar.indexOfFirst { it.id == song.id }
|
||||
if (i >= 0) onPlay(spielbar, i)
|
||||
else android.widget.Toast.makeText(
|
||||
ctx, "Dieser Titel ist noch nicht heruntergeladen",
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
@@ -182,4 +209,10 @@ fun OfflineDetailScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -27,7 +30,9 @@ import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||
|
||||
private fun normTrack(s: String?) =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
|
||||
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
|
||||
// every downloaded JP track "match" every missing one in the partial-album split).
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
|
||||
|
||||
@Composable
|
||||
fun PartialAlbumScreen(
|
||||
@@ -39,6 +44,7 @@ fun PartialAlbumScreen(
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val ia = partialAlbum.itunesAlbum
|
||||
val nd = partialAlbum.ndAlbum
|
||||
@@ -94,6 +100,50 @@ fun PartialAlbumScreen(
|
||||
.sortedBy { it.trackNumber ?: 999 }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
|
||||
// every track is downloaded yet) ─────────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent)
|
||||
.clickable { onPlay(downloadedSongs, 0) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsBackground)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPlay(downloadedSongs, -1) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Zufall", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
@@ -111,6 +161,7 @@ fun PartialAlbumScreen(
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(downloadedSongs, idx) },
|
||||
onStarToggle = {},
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -39,6 +40,8 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
import com.novasonic.app.ui.formatGesamtdauer
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
@@ -91,7 +94,9 @@ fun PlaylistDetailScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -174,9 +179,14 @@ fun PlaylistDetailScreen(
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne
|
||||
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
|
||||
val meta = listOfNotNull(
|
||||
pl.owner?.takeIf { it.isNotBlank() },
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
|
||||
// also keinen zusaetzlichen Aufruf.
|
||||
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
|
||||
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -214,7 +224,7 @@ fun PlaylistDetailScreen(
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -291,6 +301,12 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||
if (showRenameDialog) {
|
||||
|
||||
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||
val vm: SettingsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -80,7 +80,23 @@ fun SettingsScreen() {
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||
OutlinedButton(
|
||||
onClick = onOpenStats,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onOpenCompare,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
@@ -106,6 +122,76 @@ fun SettingsScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
|
||||
SectionHeader("▶ Wiedergabe")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text(
|
||||
if (state.wischenWechseltTitel)
|
||||
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
|
||||
else
|
||||
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.wischenWechseltTitel,
|
||||
onCheckedChange = { vm.onWischenWechseltTitel(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── 📥 Downloads ─────────────────────────────────────────────────────
|
||||
SectionHeader("📥 Offline-Downloads")
|
||||
|
||||
var wlanWarnung by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
|
||||
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
|
||||
checked = state.downloadWifiOnly,
|
||||
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (wlanWarnung) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { wlanWarnung = false },
|
||||
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
|
||||
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
|
||||
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { wlanWarnung = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||
@@ -178,6 +264,9 @@ fun SettingsScreen() {
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
// Vorbelegt aus BuildConfig (siehe build.gradle.kts): auf einem frischen Geraet muss nichts
|
||||
// eingetippt werden. Das Feld bleibt trotzdem bedienbar — zieht der Dienst um, laesst sich
|
||||
// das hier korrigieren, ohne ein neues APK zu bauen.
|
||||
NsTextField(
|
||||
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
@@ -186,6 +275,15 @@ fun SettingsScreen() {
|
||||
)
|
||||
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
||||
|
||||
NsTextField(
|
||||
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
|
||||
label = "NovaSonic Scrobble URL",
|
||||
hint = "Play-Log für Statistiken (Port 3710)",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
|
||||
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -18,9 +29,13 @@ import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */
|
||||
private const val GEZEIGT = 10
|
||||
|
||||
@Composable
|
||||
fun StarredScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
@@ -28,18 +43,29 @@ fun StarredScreen(
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
onOpenNews: () -> Unit = {},
|
||||
onOpenFollowed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
|
||||
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
|
||||
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
|
||||
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
songs = repo.getStarred().song
|
||||
val markiert = repo.getStarred()
|
||||
songs = markiert.song
|
||||
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
|
||||
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
@@ -57,12 +83,25 @@ fun StarredScreen(
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
|
||||
// "was gibt es Neues von denen" nahe.
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
TextButton(onClick = onOpenNews) {
|
||||
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
Row(
|
||||
@@ -100,12 +139,53 @@ fun StarredScreen(
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
} else if (sorted.isEmpty()) {
|
||||
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = listState) {
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
|
||||
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte
|
||||
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
|
||||
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
|
||||
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
|
||||
}
|
||||
if (gefolgteKuenstler.size > GEZEIGT) {
|
||||
item(key = "alle_kuenstler") {
|
||||
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (gemerkteAlben.isNotEmpty()) {
|
||||
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(gemerkteAlben, key = { it.id }) { al ->
|
||||
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
|
||||
item { FavoritenLabel("Songs (${sorted.size})") }
|
||||
}
|
||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
@@ -119,6 +199,58 @@ fun StarredScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoritenLabel(titel: String) {
|
||||
Text(
|
||||
titel,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
|
||||
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
|
||||
AsyncImage(
|
||||
model = bild, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("→", fontSize = 26.sp, color = NsAccent)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.StatsView
|
||||
import com.novasonic.app.viewmodel.StatsViewModel
|
||||
|
||||
private val MONTHS = listOf(
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: StatsViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Meine Statistiken",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Navigator: scope chips + year/month stepper ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
|
||||
val active = v == state.view
|
||||
Text(
|
||||
l,
|
||||
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (active) Color.White else NsOnSurface2,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) NsAccent else Color.Transparent)
|
||||
.clickable { vm.setView(v) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
if (state.view != StatsView.ALL) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||
Text(
|
||||
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
!state.configured -> CenterMessage(
|
||||
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||
)
|
||||
state.error != null -> CenterMessage(state.error!!, NsError)
|
||||
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||
)
|
||||
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsContent(
|
||||
d: ScrobbleStats,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
// Tiles
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
|
||||
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
|
||||
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
|
||||
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (d.series.isNotEmpty()) {
|
||||
SectionHeader("Verlauf")
|
||||
SeriesChart(d.series)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topArtists.isNotEmpty()) {
|
||||
SectionHeader("Top-Künstler")
|
||||
d.topArtists.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topTracks.isNotEmpty()) {
|
||||
SectionHeader("Top-Songs")
|
||||
d.topTracks.forEachIndexed { i, t ->
|
||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topAlbums.isNotEmpty()) {
|
||||
SectionHeader("Top-Alben")
|
||||
d.topAlbums.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topGenres.isNotEmpty()) {
|
||||
SectionHeader("Top-Genres")
|
||||
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.byHour.any { it.plays > 0 }) {
|
||||
SectionHeader("Hör-Uhr (Stunde)")
|
||||
HourClock(d.byHour.map { it.plays })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRow(rank: Int, name: String, plays: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Text(
|
||||
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
|
||||
)
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
glyph,
|
||||
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
|
||||
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(t: String) {
|
||||
Text(
|
||||
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopRow(
|
||||
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
|
||||
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
series.forEach { s ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(20.dp)
|
||||
.height((6 + (84 * s.plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||
.background(NsAccent),
|
||||
)
|
||||
Text(
|
||||
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HourClock(hours: List<Int>) {
|
||||
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
) {
|
||||
hours.forEachIndexed { h, plays ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height((2 + (48 * plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
|
||||
.background(if (plays > 0) NsAccent else NsSurface2),
|
||||
)
|
||||
Text(
|
||||
if (h % 6 == 0) h.toString() else "",
|
||||
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
// "3h 12m" / "12m" / "45s"
|
||||
private fun fmtTime(sec: Long): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
return when {
|
||||
h > 0 -> "${h}h ${m}m"
|
||||
m > 0 -> "${m}m"
|
||||
else -> "${sec}s"
|
||||
}
|
||||
}
|
||||
|
||||
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
|
||||
private fun seriesLabel(b: String): String = when {
|
||||
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
|
||||
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
|
||||
else -> b
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||
object NsColors {
|
||||
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
|
||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||
var bg by mutableStateOf(Color(0xFF070D14))
|
||||
@@ -84,10 +85,25 @@ val NS_THEMES = listOf(
|
||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
||||
),
|
||||
NsTheme(
|
||||
"violet", "Violet",
|
||||
Color(0xFFA78BFA), Color(0xFF7C5CF0),
|
||||
Color(0xFF0D0916), Color(0xFF140E20),
|
||||
Color(0xFF221835), Color(0xFF2D2145),
|
||||
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
|
||||
),
|
||||
NsTheme(
|
||||
"crimson", "Crimson",
|
||||
Color(0xFFE05263), Color(0xFFB03445),
|
||||
Color(0xFF120708), Color(0xFF1A0B0D),
|
||||
Color(0xFF2B1316), Color(0xFF3A1A1E),
|
||||
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
|
||||
),
|
||||
)
|
||||
|
||||
fun applyNsTheme(themeId: String) {
|
||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||
NsColors.themeId = t.id
|
||||
NsColors.accent = t.accent
|
||||
NsColors.accentDim = t.accentDim
|
||||
NsColors.bg = t.bg
|
||||
|
||||
@@ -21,6 +21,10 @@ data class AlbumUiState(
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
|
||||
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||
enum class NewsReiter { RELEASES, KONZERTE }
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
val keinSchluessel: Boolean = false,
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
val tage: Int = 90,
|
||||
val isLoading: Boolean = true,
|
||||
val keineGefolgten: Boolean = false,
|
||||
val keinBackend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
|
||||
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
|
||||
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
|
||||
*/
|
||||
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ArtistNewsUiState())
|
||||
val state: StateFlow<ArtistNewsUiState> = _state
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||
fun zeigeReiter(r: NewsReiter) {
|
||||
_state.value = _state.value.copy(reiter = r)
|
||||
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
|
||||
fun setzeTage(tage: Int) = laden(tage)
|
||||
|
||||
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
|
||||
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
fun ladeKonzerte() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null,
|
||||
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
|
||||
_state.value = _state.value.copy(
|
||||
konzerte = antwort.konzerte,
|
||||
keinSchluessel = antwort.keinSchluessel,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val markiert = repo.getStarred()
|
||||
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
|
||||
|
||||
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
|
||||
// Deezers Album-Id hat mit Navidromes nichts zu tun.
|
||||
val vorhanden = withContext(Dispatchers.IO) {
|
||||
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
|
||||
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
|
||||
haeppchen.map { k ->
|
||||
async {
|
||||
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
}
|
||||
(ausFavoriten + ausBibliothek).toSet()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
releases = antwort.releases,
|
||||
ohneZuordnung = antwort.ohneZuordnung,
|
||||
vorhanden = vorhanden,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun hatSchon(rel: ArtistRelease): Boolean =
|
||||
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
|
||||
|
||||
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
|
||||
val request = DownloadRequest(
|
||||
jobId = "job_${System.currentTimeMillis()}",
|
||||
type = "album",
|
||||
title = rel.title,
|
||||
artist = rel.artistName,
|
||||
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
|
||||
collectionId = rel.albumId.toLongOrNull(),
|
||||
itunesId = rel.albumId.toLongOrNull(),
|
||||
)
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun norm(s: String) = s.lowercase()
|
||||
.replace(Regex("[^a-z0-9]"), "")
|
||||
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.novasonic.app.api.EventsClient
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.api.model.ArtistInfo
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
@@ -40,19 +41,34 @@ data class ArtistUiState(
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Concerts
|
||||
val events: List<ConcertEvent> = emptyList(),
|
||||
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
|
||||
val showEventsSheet: Boolean = false,
|
||||
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||
val fanCount: Long? = null,
|
||||
// Deezer artist photo — hero fallback when Navidrome's agents have no image (niche artists)
|
||||
val deezerImage: String? = null,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
|
||||
// and the picker state (link/id paste + Deezer search candidates).
|
||||
val pin: ArtistOverride? = null,
|
||||
val pinnedImage: String? = null,
|
||||
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
|
||||
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
|
||||
val pinnedRelated: List<ItunesItem> = emptyList(),
|
||||
val showPinPicker: Boolean = false,
|
||||
val pinSearching: Boolean = false,
|
||||
val pinCandidates: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
|
||||
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
|
||||
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
|
||||
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
@@ -60,6 +76,12 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
// Scroll of the artist page + the "alle anzeigen" overlay, hoisted so tapping an album/artist and
|
||||
// coming back returns you to the same spot (the artist route entry, hence this VM, is retained).
|
||||
val pageListState = androidx.compose.foundation.lazy.LazyListState()
|
||||
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
|
||||
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
@@ -85,20 +107,31 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||
val top = topDef.await()
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
|
||||
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
|
||||
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
|
||||
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
|
||||
val pin = pinDef.await()
|
||||
|
||||
_state.value = ArtistUiState(
|
||||
artist = artist, info = info, topSongs = top,
|
||||
isLoading = false, itunesLoading = true,
|
||||
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
// Fetch iTunes + events in parallel without blocking the main screen
|
||||
launch { loadItunesData(artist.name) }
|
||||
launch { loadItunesData(artist.name, pin) }
|
||||
launch {
|
||||
val backendUrl = repo.getBackendUrl()
|
||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(events = ev)
|
||||
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
|
||||
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
|
||||
.onFailure { e ->
|
||||
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
|
||||
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||
@@ -106,27 +139,49 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItunesData(artistName: String) {
|
||||
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
|
||||
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
|
||||
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
|
||||
if (pinnedId != null) {
|
||||
_state.value = _state.value.copy(
|
||||
fanCount = pin.fanCount ?: _state.value.fanCount,
|
||||
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
|
||||
)
|
||||
coroutineScope {
|
||||
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
|
||||
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
|
||||
_state.value = _state.value.copy(
|
||||
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
|
||||
pinnedRelated = relatedD.await(), itunesLoading = false,
|
||||
)
|
||||
}
|
||||
enrichPartialCounts()
|
||||
return
|
||||
}
|
||||
// Not pinned → clear any stale pinned-related from a previous pin.
|
||||
_state.value = _state.value.copy(pinnedRelated = emptyList())
|
||||
|
||||
val searchResp = runCatching {
|
||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||
}.getOrNull()
|
||||
|
||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
// artists are sorted by fan count (ItunesClient), so the first name match is the most popular
|
||||
// one — disambiguates same-name artists (5x "Joji").
|
||||
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
|
||||
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
|
||||
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val q = norm(artistName)
|
||||
val iArtist = (
|
||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
||||
?: artists.find {
|
||||
val n = it.artistName ?: return@find false
|
||||
artists.firstOrNull { norm(it.artistName) == q }
|
||||
?: artists.firstOrNull {
|
||||
val n = it.artistName ?: return@firstOrNull false
|
||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||
}
|
||||
?: artists.firstOrNull()
|
||||
)
|
||||
val iArtistId = iArtist?.artistId
|
||||
if (iArtist != null) _state.value = _state.value.copy(
|
||||
fanCount = iArtist.fanCount ?: _state.value.fanCount,
|
||||
deezerImage = iArtist.artworkUrl100?.takeIf { it.isNotBlank() } ?: _state.value.deezerImage,
|
||||
)
|
||||
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
@@ -138,11 +193,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
itunesLoading = false,
|
||||
)
|
||||
}
|
||||
enrichPartialCounts()
|
||||
} else {
|
||||
_state.value = _state.value.copy(itunesLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
|
||||
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
|
||||
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
|
||||
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
|
||||
* a couple of calls, not the whole discography.
|
||||
*/
|
||||
private suspend fun enrichPartialCounts() {
|
||||
val ndAlbums = _state.value.artist?.album ?: return
|
||||
val iAlbums = _state.value.iAlbums
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val enriched = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) ia.copy(trackCount = n) else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
_state.value = _state.value.copy(iAlbums = enriched)
|
||||
}
|
||||
|
||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||
|
||||
fun loadSubAlbum(album: ItunesItem) {
|
||||
@@ -205,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
isShuffling = false,
|
||||
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
|
||||
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -275,6 +360,91 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
// ── Artist pin (same-name disambiguation) ────────────────────────────────
|
||||
|
||||
fun openPinPicker() {
|
||||
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
|
||||
val name = _state.value.artist?.name ?: return
|
||||
viewModelScope.launch {
|
||||
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
|
||||
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
|
||||
|
||||
/** Pin one of the searched candidates. */
|
||||
fun pinCandidate(item: ItunesItem) {
|
||||
val dzId = item.artistId ?: return
|
||||
applyPin(ArtistOverride(
|
||||
deezerArtistId = dzId.toString(),
|
||||
name = item.artistName ?: _state.value.artist?.name ?: "",
|
||||
image = item.artworkUrl100 ?: "",
|
||||
fanCount = item.fanCount,
|
||||
))
|
||||
}
|
||||
|
||||
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
|
||||
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
|
||||
val id = parseDeezerArtistId(raw)?.toLongOrNull()
|
||||
if (id == null) { onResult(false); return }
|
||||
viewModelScope.launch {
|
||||
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
|
||||
if (a?.artistId == null) { onResult(false); return@launch }
|
||||
applyPin(ArtistOverride(
|
||||
deezerArtistId = a.artistId.toString(),
|
||||
name = a.artistName ?: _state.value.artist?.name ?: "",
|
||||
image = a.artworkUrl100 ?: "",
|
||||
fanCount = a.fanCount,
|
||||
))
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
|
||||
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
|
||||
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
|
||||
*/
|
||||
suspend fun libraryArtistId(name: String?): String? {
|
||||
val n = name?.trim()?.lowercase() ?: return null
|
||||
if (n.isEmpty()) return null
|
||||
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
|
||||
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
|
||||
}
|
||||
|
||||
fun clearPin() {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.removeArtistOverride(id) }
|
||||
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
|
||||
loadItunesData(_state.value.artist?.name ?: "", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPin(ov: ArtistOverride) {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.setArtistOverride(id, ov) }
|
||||
_state.value = _state.value.copy(
|
||||
pin = ov,
|
||||
pinnedImage = ov.image.takeIf { it.isNotBlank() },
|
||||
showPinPicker = false,
|
||||
itunesLoading = true,
|
||||
)
|
||||
loadItunesData(_state.value.artist?.name ?: "", ov)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
|
||||
private fun parseDeezerArtistId(raw: String): String? {
|
||||
val s = raw.trim()
|
||||
if (s.isEmpty()) return null
|
||||
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
|
||||
return if (s.matches(Regex("\\d+"))) s else null
|
||||
}
|
||||
|
||||
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||
// global bar) after leaving this screen.
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.CompareResult
|
||||
import com.novasonic.app.api.model.ScrobbleUser
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CompareUiState(
|
||||
val loading: Boolean = true,
|
||||
val configured: Boolean = true,
|
||||
val error: String? = null,
|
||||
val me: String = "",
|
||||
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
|
||||
val partner: String? = null, // chosen userB; null → show picker
|
||||
val result: CompareResult? = null,
|
||||
)
|
||||
|
||||
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
|
||||
class CompareViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(CompareUiState())
|
||||
val state: StateFlow<CompareUiState> = _state
|
||||
|
||||
init { loadUsers() }
|
||||
|
||||
fun loadUsers() {
|
||||
viewModelScope.launch {
|
||||
_state.value = CompareUiState(loading = true)
|
||||
val settings = repo.appSettingsFlow().first()
|
||||
if (settings.scrobbleUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(loading = false, configured = false)
|
||||
return@launch
|
||||
}
|
||||
val resp = repo.fetchScrobbleUsers()
|
||||
_state.value = if (resp == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, error = null,
|
||||
me = resp.me,
|
||||
users = resp.users.filter { it.user != resp.me },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to the partner picker. */
|
||||
fun clearPartner() {
|
||||
_state.value = _state.value.copy(partner = null, result = null, error = null)
|
||||
}
|
||||
|
||||
fun choosePartner(name: String) {
|
||||
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
|
||||
viewModelScope.launch {
|
||||
val res = repo.fetchScrobbleCompare(name)
|
||||
_state.value = if (res == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(loading = false, result = res, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.ItunesRssEntry
|
||||
import com.novasonic.app.api.model.Song
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import com.novasonic.app.api.model.artworkHd
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -75,6 +77,13 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
|
||||
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
|
||||
val searchListState = LazyListState()
|
||||
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
|
||||
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
|
||||
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
|
||||
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
@@ -171,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||
}
|
||||
|
||||
/** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id
|
||||
* jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die
|
||||
* Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */
|
||||
fun openArtistOfAlbum(album: ItunesItem) {
|
||||
val id = album.artistId ?: return
|
||||
val name = album.artistName ?: return
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null)
|
||||
openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name))
|
||||
}
|
||||
|
||||
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
|
||||
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
|
||||
|
||||
@@ -184,22 +203,66 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val artistName = artist.artistName ?: return@launch
|
||||
|
||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
|
||||
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
|
||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
val ndAlbums = ndAlbumsD.await()
|
||||
val iAlbums = iAlbumsD.await()
|
||||
// Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal
|
||||
// nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche.
|
||||
val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null)
|
||||
runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist
|
||||
else artist
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = artist,
|
||||
artist = voll,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbumsD.await(),
|
||||
iAlbums = iAlbumsD.await(),
|
||||
ndAlbums = ndAlbums,
|
||||
iAlbums = iAlbums,
|
||||
iTracks = iTracksD.await(),
|
||||
topSongs = topSongsD.await(),
|
||||
),
|
||||
)
|
||||
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
|
||||
// Resolve the real total for matched albums (like web) so partials are detected.
|
||||
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
|
||||
if (enriched !== iAlbums) {
|
||||
_state.value.artistDetail?.let { d ->
|
||||
if (d.artist.artistId == artist.artistId) {
|
||||
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
|
||||
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
|
||||
private suspend fun enrichPartialCounts(
|
||||
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
|
||||
): List<ItunesItem> {
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
var changed = false
|
||||
val out = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
return if (changed) out else iAlbums
|
||||
}
|
||||
|
||||
fun closeArtistDetail() {
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||
@@ -278,7 +341,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onSuccess {
|
||||
// App-lifetime poll → success/failure notification even after leaving the screen
|
||||
// (previously only the artist page + importer notified; search/Bibliothek was silent).
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ImportResolveResponse
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -26,11 +27,20 @@ data class ImporterUiState(
|
||||
val jobMessage: String? = null,
|
||||
val jobCompleted: Int = 0,
|
||||
val jobTotal: Int = 0,
|
||||
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||
val previewingId: Long? = null,
|
||||
|
||||
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
|
||||
val createPlaylist: Boolean = true,
|
||||
val playlistName: String = "",
|
||||
val playlistStatus: String? = null,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
|
||||
private val _state = MutableStateFlow(ImporterUiState())
|
||||
val state: StateFlow<ImporterUiState> = _state
|
||||
@@ -41,7 +51,31 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||
}
|
||||
}
|
||||
// Mirror the app-level preview so the track rows show which clip is playing.
|
||||
viewModelScope.launch {
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
|
||||
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
|
||||
|
||||
fun togglePreview(track: ImportTrack) {
|
||||
val url = track.previewUrl ?: return
|
||||
player.pause() // don't overlap the 30s clip with library playback
|
||||
preview.toggle(
|
||||
previewId(track), url,
|
||||
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
|
||||
)
|
||||
}
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
|
||||
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
@@ -147,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
val cur = _state.value
|
||||
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
|
||||
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
|
||||
cur.resolved.tracks)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -156,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
|
||||
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
|
||||
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
|
||||
*/
|
||||
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
|
||||
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
|
||||
runCatching { repo.startScan() }
|
||||
delay(4000)
|
||||
|
||||
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
|
||||
val ids = mutableListOf<String>()
|
||||
var fehlend = 0
|
||||
for (t in tracks) {
|
||||
val titel = t.trackName ?: continue
|
||||
val treffer = runCatching {
|
||||
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
|
||||
val gefunden = repo.search(q).song
|
||||
gefunden.firstOrNull { norm(it.title) == norm(titel) }
|
||||
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
|
||||
}.getOrNull()
|
||||
if (treffer != null) ids += treffer.id else fehlend++
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
|
||||
}
|
||||
|
||||
if (ids.isEmpty()) {
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
|
||||
return
|
||||
}
|
||||
val res = runCatching { repo.createPlaylist(name, ids) }
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = res.fold(
|
||||
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
|
||||
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
|
||||
{ "Playlist fehlgeschlagen: ${it.message}" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
@@ -9,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -22,7 +25,12 @@ data class LibraryUiState(
|
||||
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
|
||||
val gefolgteIds: Set<String> = emptySet(),
|
||||
val nurGefolgte: Boolean = false,
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||
val currentUser: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -34,10 +42,27 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||
val state: StateFlow<LibraryUiState> = _state
|
||||
|
||||
init { loadPlaylists() }
|
||||
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
|
||||
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
|
||||
// → back at the top, as expected.
|
||||
val albumsGridState = LazyGridState()
|
||||
val artistsListState = LazyListState()
|
||||
|
||||
init {
|
||||
loadPlaylists()
|
||||
viewModelScope.launch {
|
||||
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||
_state.value = _state.value.copy(currentUser = u)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAlbumSort(sort: AlbumSort) {
|
||||
if (sort == _state.value.albumSort) return
|
||||
_state.value = _state.value.copy(albumSort = sort)
|
||||
// Eine andere Sortierung heißt eine andere Liste — dort oben anfangen. Bewusst hier und
|
||||
// nicht als LaunchedEffect im Screen: der feuert auch bei jedem Neu-Composen (z. B. nach
|
||||
// dem Zurück aus einem Album) und hätte die gemerkte Scrollposition jedes Mal verworfen.
|
||||
viewModelScope.launch { albumsGridState.scrollToItem(0) }
|
||||
}
|
||||
|
||||
fun selectTab(tab: LibraryTab) {
|
||||
@@ -71,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun nurGefolgteUmschalten(an: Boolean) {
|
||||
_state.value = _state.value.copy(nurGefolgte = an)
|
||||
if (an) ladeGefolgte()
|
||||
}
|
||||
|
||||
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
|
||||
* Künstler nicht noch in der Liste steht. */
|
||||
private fun ladeGefolgte() = viewModelScope.launch {
|
||||
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
|
||||
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.toSong
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
|
||||
}
|
||||
|
||||
/** Nur FERTIGE Titel: wartende oder fehlgeschlagene haben keine lokale Datei und wuerden aus
|
||||
* dem Netz gezogen — offline schlaegt die Wiedergabe dann fehl, bei grossen Playlists
|
||||
* gleich beim ersten Titel. */
|
||||
suspend fun songsForGroup(groupId: String): List<Song> =
|
||||
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
|
||||
offlineRepo.tracksForGroup(groupId)
|
||||
.filter { it.state == DownloadState.DONE }
|
||||
.map { it.toSong() }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -22,6 +23,8 @@ data class PlaylistUiState(
|
||||
val isSaving: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||
val currentUser: String = "",
|
||||
)
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
@@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init { load() }
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||
_state.value = _state.value.copy(currentUser = u)
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
val id = playlistId
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.SearchResult3
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -16,6 +18,8 @@ data class SearchUiState(
|
||||
val searchResult: SearchResult3? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
|
||||
val overrides: Map<String, ArtistOverride> = emptyMap(),
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
@@ -25,8 +29,18 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state
|
||||
|
||||
// Results scroll — hoisted so it survives a drill-in/back within the search.
|
||||
val listState = LazyListState()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
|
||||
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) = onSearch(q)
|
||||
|
||||
fun onSearch(q: String) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.theme.NsColors
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
@@ -28,24 +29,30 @@ data class SettingsUiState(
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
val downloadWifiOnly: Boolean = true,
|
||||
val wischenWechseltTitel: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val savedOk: Boolean = false,
|
||||
val error: String? = null,
|
||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
|
||||
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
|
||||
val state: StateFlow<SettingsUiState> = _state
|
||||
|
||||
init {
|
||||
@@ -57,12 +64,15 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey,
|
||||
backendUrl = s.backendUrl,
|
||||
scrobbleUrl = s.scrobbleUrl,
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl,
|
||||
absApiKey = s.absApiKey,
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
downloadWifiOnly = s.downloadWifiOnly,
|
||||
wischenWechseltTitel = s.wischenWechseltTitel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -73,11 +83,14 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
|
||||
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
|
||||
fun onWischenWechseltTitel(v: Boolean) = update { it.copy(wischenWechseltTitel = v, savedOk = false) }
|
||||
|
||||
fun onTheme(id: String) {
|
||||
update { it.copy(theme = id) }
|
||||
@@ -124,6 +137,31 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun testScrobble() {
|
||||
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||
}
|
||||
val resp = http.get("$url/scrobble/health")
|
||||
http.close()
|
||||
if (resp.status == HttpStatusCode.OK) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||
} else {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||
@@ -139,13 +177,19 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey.trim(),
|
||||
backendUrl = s.backendUrl.trimEnd('/'),
|
||||
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl.trimEnd('/'),
|
||||
absApiKey = s.absApiKey.trim(),
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
downloadWifiOnly = s.downloadWifiOnly,
|
||||
wischenWechseltTitel = s.wischenWechseltTitel,
|
||||
))
|
||||
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
|
||||
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.
|
||||
offlineRepo.netzbedingungAktualisieren()
|
||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
enum class StatsView { ALL, YEAR, MONTH }
|
||||
|
||||
data class StatsUiState(
|
||||
val loading: Boolean = true,
|
||||
val configured: Boolean = true,
|
||||
val error: String? = null,
|
||||
val view: StatsView = StatsView.ALL,
|
||||
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
||||
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
||||
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
|
||||
val stats: ScrobbleStats? = null,
|
||||
)
|
||||
|
||||
class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(StatsUiState())
|
||||
val state: StateFlow<StatsUiState> = _state
|
||||
|
||||
init { load() }
|
||||
|
||||
fun setView(v: StatsView) {
|
||||
if (v == _state.value.view) return
|
||||
_state.value = _state.value.copy(view = v)
|
||||
load()
|
||||
}
|
||||
|
||||
/** Step the current year/month window by ±1 (no-op in ALL). */
|
||||
fun step(dir: Int) {
|
||||
val s = _state.value
|
||||
when (s.view) {
|
||||
StatsView.YEAR -> _state.value = s.copy(year = s.year + dir)
|
||||
StatsView.MONTH -> {
|
||||
var m = s.month + dir
|
||||
var y = s.year
|
||||
if (m < 0) { m = 11; y-- }
|
||||
if (m > 11) { m = 0; y++ }
|
||||
_state.value = s.copy(year = y, month = m)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
/** True when the shown window is the current one — used to disable stepping into the future. */
|
||||
fun atCurrent(): Boolean {
|
||||
val s = _state.value
|
||||
val now = Calendar.getInstance()
|
||||
return when (s.view) {
|
||||
StatsView.YEAR -> s.year >= now.get(Calendar.YEAR)
|
||||
StatsView.MONTH -> s.year > now.get(Calendar.YEAR) ||
|
||||
(s.year == now.get(Calendar.YEAR) && s.month >= now.get(Calendar.MONTH))
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
|
||||
fun atStart(): Boolean {
|
||||
val s = _state.value
|
||||
val first = s.firstPlayedAt ?: return false
|
||||
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
|
||||
val fy = c.get(Calendar.YEAR)
|
||||
val fm = c.get(Calendar.MONTH)
|
||||
return when (s.view) {
|
||||
StatsView.YEAR -> s.year <= fy
|
||||
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOf(year: Int, month: Int, day: Int): Long {
|
||||
val c = Calendar.getInstance().apply {
|
||||
clear()
|
||||
set(year, month, day, 0, 0, 0)
|
||||
}
|
||||
return c.timeInMillis / 1000
|
||||
}
|
||||
|
||||
private fun range(): Triple<Long, Long, String> {
|
||||
val s = _state.value
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
return when (s.view) {
|
||||
StatsView.ALL -> Triple(0L, nowSec + 1, "month")
|
||||
StatsView.YEAR -> Triple(startOf(s.year, 0, 1), startOf(s.year + 1, 0, 1), "month")
|
||||
StatsView.MONTH -> Triple(startOf(s.year, s.month, 1), startOf(s.year, s.month + 1, 1), "day")
|
||||
}
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val settings = repo.appSettingsFlow().first()
|
||||
if (settings.scrobbleUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(loading = false, configured = false)
|
||||
return@launch
|
||||
}
|
||||
val (from, to, bucket) = range()
|
||||
val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
|
||||
val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10)
|
||||
_state.value = if (stats == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, stats = stats,
|
||||
// Keep the earliest-play bound once known (a windowed query may return null for it).
|
||||
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- Benachrichtigungen: Honigtopf mit Brief statt der ueblichen Glocke.
|
||||
Reine Strichzeichnung wie die uebrigen Symbole, damit Icon(tint = …) sie einfaerben kann. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Die Zeichnung fuellt ihr Feld nur zu etwa zwei Dritteln, Material-Symbole fuellen es ganz.
|
||||
Ohne dieses Aufziehen wirkt der Topf neben dem Zahnrad kleiner, obwohl beide 24 dp sind. -->
|
||||
<group
|
||||
android:pivotX="12"
|
||||
android:pivotY="11.95"
|
||||
android:scaleX="1.38"
|
||||
android:scaleY="1.38">
|
||||
|
||||
<!-- Deckelrand -->
|
||||
<path
|
||||
android:pathData="M4.7,4.6 L19.3,4.6 A1.1,1.1 0 0 1 20.4,5.7 L20.4,6.5 A1.1,1.1 0 0 1 19.3,7.6 L4.7,7.6 A1.1,1.1 0 0 1 3.6,6.5 L3.6,5.7 A1.1,1.1 0 0 1 4.7,4.6 Z"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.35"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Topfkoerper, nach unten leicht verjuengt -->
|
||||
<path
|
||||
android:pathData="M5.5,7.6 c-1.1,3.9 -0.7,8.2 0.7,10.4 a2,2 0 0 0 1.7,0.9 h8.2 a2,2 0 0 0 1.7,-0.9 c1.4,-2.2 1.8,-6.5 0.7,-10.4"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.35"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Brief -->
|
||||
<path
|
||||
android:pathData="M9,11.7 L15,11.7 A0.7,0.7 0 0 1 15.7,12.4 L15.7,15.6 A0.7,0.7 0 0 1 15,16.3 L9,16.3 A0.7,0.7 0 0 1 8.3,15.6 L8.3,12.4 A0.7,0.7 0 0 1 9,11.7 Z"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Brieflasche -->
|
||||
<path
|
||||
android:pathData="M8.6,12.1 L12,14.4 L15.4,12.1"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- The downloaded update APK lives in cacheDir/updates and is shared with the system installer. -->
|
||||
<cache-path name="updates" path="updates/"/>
|
||||
</paths>
|
||||
File mode changed.
@@ -0,0 +1,32 @@
|
||||
# NovaSonic download backend — bakes the heavy deps (ffmpeg, Deno, yt-dlp, node libs) so restarts are
|
||||
# instant instead of re-installing everything on every boot. Build context = ./backend.
|
||||
FROM node:22-slim
|
||||
|
||||
# System deps: python (yt-dlp), ffmpeg (tagging/convert), curl/git/unzip (installers), ca-certs.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg curl git unzip ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# yt-dlp (nightly) + the Spotify helper tooling.
|
||||
RUN pip install --break-system-packages -U --pre 'yt-dlp[default]' \
|
||||
&& pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader'
|
||||
|
||||
# Deno — yt-dlp uses it to solve YouTube's JS signature/nsig challenge.
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||
ENV PATH="/root/.deno/bin:${PATH}"
|
||||
|
||||
# Node libs the server loads (express, kuromoji) — kept out of the app dir like the compose setup did.
|
||||
WORKDIR /deps
|
||||
RUN npm install express kuromoji
|
||||
ENV NODE_PATH=/deps/node_modules
|
||||
|
||||
# App code.
|
||||
WORKDIR /app
|
||||
COPY . /app/
|
||||
|
||||
ENV PORT=3700 DATA_PATH=/data
|
||||
EXPOSE 3700
|
||||
|
||||
# Refresh yt-dlp on start (heavy deps already baked, so this is a quick pull) — non-fatal if offline,
|
||||
# the baked version still works. Keeps the "always-latest yt-dlp" behaviour of the old compose command.
|
||||
CMD ["sh","-c","pip install --break-system-packages -U --pre 'yt-dlp[default]' >/dev/null 2>&1 || true; node novasonic-server.js"]
|
||||
+14
-2
@@ -69,8 +69,20 @@ function pickTrackMatch(results, artist, title) {
|
||||
const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
|
||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
||||
const titleOnly = results.find(t => normalizeTitle(t.title) === normT);
|
||||
return strict || (!isJp ? titleOnly || null : null);
|
||||
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
|
||||
// other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES
|
||||
// the old blind title-only fallback, which grabbed a same-titled song by a completely different
|
||||
// artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year.
|
||||
const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
const wantA = _n(normA);
|
||||
const artistOk = candA => {
|
||||
if (!wantA || !candA) return false;
|
||||
if (candA === wantA) return true;
|
||||
const short = Math.min(candA.length, wantA.length);
|
||||
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
|
||||
};
|
||||
const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name)));
|
||||
return strict || (isJp ? null : loose) || null;
|
||||
}
|
||||
|
||||
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// ─── KONZERTE GEFOLGTER KÜNSTLER ──────────────────────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den
|
||||
// Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js.
|
||||
//
|
||||
// Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und
|
||||
// pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
//
|
||||
// Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über
|
||||
// Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein
|
||||
// kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft:
|
||||
// der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst
|
||||
// später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG).
|
||||
const https = require('https');
|
||||
|
||||
const _cache = new Map(); // nameLower → { ts, termine }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig
|
||||
|
||||
const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
|
||||
function holen(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { Accept: 'application/json' } }, up => {
|
||||
let body = '';
|
||||
up.on('data', d => body += d);
|
||||
up.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (up.statusCode !== 200 || data.fault || data.errors) {
|
||||
return reject(new Error(`Ticketmaster ${up.statusCode}`));
|
||||
}
|
||||
resolve(data);
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */
|
||||
async function termineFuer(name, apiKey) {
|
||||
const key = norm(name);
|
||||
if (!key) return [];
|
||||
const c = _cache.get(key);
|
||||
if (c && Date.now() - c.ts < TTL) return c.termine;
|
||||
|
||||
const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const url = 'https://app.ticketmaster.com/discovery/v2/events.json' +
|
||||
`?keyword=${encodeURIComponent(name)}&classificationName=music` +
|
||||
`&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const data = await holen(url);
|
||||
const roh = data?._embedded?.events || [];
|
||||
const termine = roh.map(e => {
|
||||
const ort = e._embedded?.venues?.[0] || {};
|
||||
const start = e.dates?.start || {};
|
||||
// Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt.
|
||||
// Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert.
|
||||
const zeit = (start.localDate
|
||||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||
: start.dateTime) || null;
|
||||
const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled';
|
||||
const attraktionen = (e._embedded?.attractions || []).map(a => a.name || '');
|
||||
return {
|
||||
eventId: e.id || '',
|
||||
titel: e.name || '',
|
||||
datum: zeit,
|
||||
ort: ort.name || '',
|
||||
stadt: ort.city?.name || '',
|
||||
land: ort.country?.name || '',
|
||||
url: e.url || '',
|
||||
abgesagt: aus,
|
||||
_namen: [e.name || '', ...attraktionen],
|
||||
};
|
||||
}).filter(t => t.datum);
|
||||
|
||||
// Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder
|
||||
// Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen.
|
||||
const gesucht = key;
|
||||
const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht)));
|
||||
const sauber = gefiltert.map(({ _namen, ...rest }) => rest);
|
||||
|
||||
_cache.set(key, { ts: Date.now(), termine: sauber });
|
||||
return sauber;
|
||||
} catch (e) {
|
||||
// Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den
|
||||
// Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird.
|
||||
if (c) return c.termine;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function inHaeppchen(items, groesse, fn) {
|
||||
const raus = [];
|
||||
for (let i = 0; i < items.length; i += groesse) {
|
||||
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler
|
||||
* @param {string} apiKey Ticketmaster-Schlüssel
|
||||
* @param {number} tage Zeitfenster nach vorn; 0 = kein Fenster (Vorgabe)
|
||||
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
|
||||
*/
|
||||
async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
|
||||
// Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
|
||||
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
|
||||
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
|
||||
const ohneTermine = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
let termine = [];
|
||||
try { termine = await termineFuer(k.name, apiKey); } catch { return []; }
|
||||
if (!termine.length) { ohneTermine.push(k.name); return []; }
|
||||
return termine
|
||||
.filter(t => new Date(t.datum).getTime() <= grenze)
|
||||
.map(t => ({ artistId: k.id, artistName: k.name, ...t }));
|
||||
});
|
||||
|
||||
// Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen,
|
||||
// die rueckwaerts laufen.
|
||||
const konzerte = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||
return { konzerte, ohneTermine };
|
||||
}
|
||||
|
||||
module.exports = { kommendeKonzerte, termineFuer, _cache };
|
||||
+107
-8
@@ -6,6 +6,19 @@ function sanitize(s) {
|
||||
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
||||
}
|
||||
|
||||
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
|
||||
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
|
||||
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
|
||||
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
|
||||
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
|
||||
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
|
||||
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
|
||||
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
|
||||
function dateinamensKuenstler(artist, fallback) {
|
||||
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
|
||||
return erster(artist) || erster(fallback) || '';
|
||||
}
|
||||
|
||||
function navidromeArtist(name) {
|
||||
if (!name) return name;
|
||||
return name
|
||||
@@ -51,17 +64,31 @@ const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
||||
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
||||
const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
|
||||
|
||||
// Version markers that make a track a DISTINCT recording — brackets containing these must NOT be
|
||||
// stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the
|
||||
// base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed).
|
||||
const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/i;
|
||||
|
||||
// Robust duplicate detection: search case-insensitively across all album folders
|
||||
// under the artist folder matching firstArtist, compare normalized title ignoring
|
||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
||||
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
||||
const norm = s => (s || '')
|
||||
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
|
||||
// "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe
|
||||
// Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen.
|
||||
.normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.toLowerCase()
|
||||
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
|
||||
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
|
||||
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
|
||||
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
|
||||
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
|
||||
.replace(/[/\\:*?"<>|_]/g, ' ')
|
||||
.replace(/[,.'!]/g, '')
|
||||
// Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
|
||||
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
|
||||
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const normArtist = norm(firstArtist);
|
||||
@@ -83,11 +110,18 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
if (!AUDIO_EXTS.includes(ext)) continue;
|
||||
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
||||
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
|
||||
// Stem may be "Artist - Title" or just "Title"
|
||||
// Candidate titles from the filename. The old code only used the part AFTER the first
|
||||
// " - " (assuming "Artist - Title"), which mis-parses titles that themselves contain
|
||||
// " - " (e.g. "Kingdom Hearts 2 - Dearly Beloved - Main Theme") or filenames with a
|
||||
// garbage/absent artist prefix ("01-01 - …") → the duplicate went undetected and got
|
||||
// re-downloaded. Now match if ANY reading equals the query title:
|
||||
// 1) the FULL stem (title with internal dashes, or no clean artist prefix)
|
||||
// 2) the part after the first " - " (normal "Artist - Title")
|
||||
const di = stem.indexOf(' - ');
|
||||
const rawTitle = di >= 0 ? stem.slice(di + 3) : stem;
|
||||
const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, ''));
|
||||
if (titleVariants.has(normStemTitle)) return path.join(albumPath, f);
|
||||
const candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
|
||||
for (const cand of candidates) {
|
||||
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) return path.join(albumPath, f);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -97,6 +131,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
|
||||
|
||||
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
|
||||
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
|
||||
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
|
||||
function normStem(s) {
|
||||
return s
|
||||
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
|
||||
.replace(/\s*[–—]\s*/g, ' - ');
|
||||
}
|
||||
|
||||
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
|
||||
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
|
||||
//
|
||||
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
|
||||
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
|
||||
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
|
||||
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
|
||||
function findFileByStem(dir, titleStem, depth = 0) {
|
||||
if (depth > 4) return null;
|
||||
const normTitle = normStem(titleStem);
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||||
if (found) return found;
|
||||
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||||
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||||
const bareStem = normStem(bare.toLowerCase());
|
||||
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
|
||||
if (bareStem === normTitle ||
|
||||
bareStem.endsWith(' - ' + normTitle) ||
|
||||
bareStem.startsWith(normTitle + ' - ') ||
|
||||
normTitle.startsWith(bareStem + ' - ')) {
|
||||
return path.join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
|
||||
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
|
||||
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
|
||||
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
|
||||
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
|
||||
// so zusammen, wie der Downloader ihn angelegt haette.
|
||||
function albumDirFromTags(musicRoot, albumArtist, album) {
|
||||
if (!musicRoot || !albumArtist) return null;
|
||||
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
|
||||
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
|
||||
const kandidaten = [];
|
||||
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
|
||||
if (!k) continue;
|
||||
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
|
||||
kandidaten.push(path.join(musicRoot, sanitize(k)));
|
||||
}
|
||||
for (const c of kandidaten) {
|
||||
try { if (fs.statSync(c).isDirectory()) return c; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch plain + synced lyrics from LRCLIB
|
||||
async function fetchLyrics(artist, title, album) {
|
||||
try {
|
||||
@@ -117,6 +215,7 @@ async function fetchLyrics(artist, title, album) {
|
||||
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||||
|
||||
module.exports = {
|
||||
sanitize, navidromeArtist,
|
||||
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||
downloadArtwork, findExistingTrack,
|
||||
normStem, findFileByStem, albumDirFromTags,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
// MusicBrainz fallback for single-track metadata — used only when Deezer finds no artist-verified
|
||||
// match (see trackmeta.js). Returns the SAME shape as deezer.lookupTrackMeta so the caller is
|
||||
// agnostic. Same matching discipline as Deezer: the artist must correspond, or we return {} and let
|
||||
// the download use its clean fallback (album_artist = song artist, album = "Singles", source cover).
|
||||
//
|
||||
// MusicBrainz rules: a descriptive User-Agent is REQUIRED and requests are rate-limited to ~1/sec.
|
||||
// We do a single recording search per lookup and throttle to stay within that budget. Cover art
|
||||
// comes from the Cover Art Archive (coverartarchive.org) by release MBID; a 404 there is tolerated
|
||||
// downstream (the download falls back to the source thumbnail).
|
||||
|
||||
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const USER_AGENT = 'NovaSonic/2.0 ( https://github.com/novasonic )';
|
||||
|
||||
// ── normalization + artist matching (self-contained; mirrors deezer.js intent) ──
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
const _normTitle = s => (s || '').toLowerCase().replace(/[\[((].*?[\]))]/g, '')
|
||||
.replace(/\s*[-–]\s*(feat|ft|featuring)\.?.*$/i, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
const _normArtist = s => stripLeadSym((s || '').split(/[;,&]/)[0].toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
|
||||
function _artistOk(candA, wantA) {
|
||||
if (!wantA || !candA) return false;
|
||||
if (candA === wantA) return true;
|
||||
const short = Math.min(candA.length, wantA.length);
|
||||
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
|
||||
}
|
||||
|
||||
// Pure: choose the best artist-verified recording for (artist, title). Prefers a recording that
|
||||
// carries a release (so we get an album). Exported for unit tests.
|
||||
function mbPickMatch(recordings, artist, title) {
|
||||
if (!Array.isArray(recordings) || !recordings.length) return null;
|
||||
const normT = _normTitle(title);
|
||||
const wantA = _normArtist(artist);
|
||||
const candArtist = rec => _normArtist(rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name);
|
||||
const matches = recordings.filter(r => _normTitle(r.title) === normT && _artistOk(candArtist(r), wantA));
|
||||
if (!matches.length) return null;
|
||||
// Prefer one with a release (album); otherwise the first artist+title match.
|
||||
return matches.find(r => Array.isArray(r.releases) && r.releases.length) || matches[0];
|
||||
}
|
||||
|
||||
const _yearOf = d => { const m = /^(\d{4})/.exec(d || ''); return m ? Number(m[1]) : undefined; };
|
||||
|
||||
// Pick the most "album-like" release: an official Album release-group, else one with a date, else first.
|
||||
function _pickRelease(rec) {
|
||||
const rels = Array.isArray(rec.releases) ? rec.releases : [];
|
||||
if (!rels.length) return null;
|
||||
const isAlbum = r => (r['release-group']?.['primary-type'] || '').toLowerCase() === 'album';
|
||||
return rels.find(r => isAlbum(r) && r.date) || rels.find(isAlbum) || rels.find(r => r.date) || rels[0];
|
||||
}
|
||||
|
||||
// Pure: map a chosen recording to the lookupTrackMeta shape. Exported for unit tests.
|
||||
function mapMbRecording(rec) {
|
||||
if (!rec) return {};
|
||||
const rel = _pickRelease(rec);
|
||||
const artistName = rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name || undefined;
|
||||
return {
|
||||
album: rel?.title || undefined,
|
||||
albumArtist: artistName, // recording search rarely carries the release artist-credit
|
||||
trackName: rec.title || undefined,
|
||||
year: _yearOf(rel?.date),
|
||||
genre: undefined, // omitted (extra call); "leave the rest alone" on fallback
|
||||
artworkUrl: rel?.id ? `https://coverartarchive.org/release/${rel.id}/front-500` : '',
|
||||
durationMs: Number.isFinite(rec.length) ? rec.length : undefined,
|
||||
metaArtistName: artistName,
|
||||
_source: 'musicbrainz',
|
||||
};
|
||||
}
|
||||
|
||||
// ── rate limiting (~1 request/sec, global to the process) ──
|
||||
let _chain = Promise.resolve();
|
||||
let _last = 0;
|
||||
function _throttle() {
|
||||
_chain = _chain.then(async () => {
|
||||
const wait = Math.max(0, 1000 - (Date.now() - _last));
|
||||
if (wait) await new Promise(r => setTimeout(r, wait));
|
||||
_last = Date.now();
|
||||
});
|
||||
return _chain;
|
||||
}
|
||||
|
||||
async function lookupTrackMeta(artist, title) {
|
||||
try {
|
||||
const a = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
|
||||
const t = (title || '').replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||
if (!a || !t) return {};
|
||||
const q = encodeURIComponent(`recording:"${t}" AND artist:"${a}"`);
|
||||
await _throttle();
|
||||
const r = await fetch(`${MB_BASE}/recording?query=${q}&fmt=json&limit=10&inc=releases`, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!r.ok) return {};
|
||||
const recordings = (await r.json()).recordings || [];
|
||||
return mapMbRecording(mbPickMatch(recordings, artist, title));
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
module.exports = { lookupTrackMeta, mbPickMatch, mapMbRecording };
|
||||
@@ -0,0 +1,95 @@
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
|
||||
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
|
||||
//
|
||||
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
|
||||
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
const { dzGet } = require('./deezer');
|
||||
|
||||
const _cache = new Map(); // deezerArtistId → { ts, alben }
|
||||
const _idCache = new Map(); // nameLower → { ts, id }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
|
||||
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
|
||||
|
||||
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
|
||||
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
|
||||
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
|
||||
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
|
||||
async function deezerIdFuer(name, pinId) {
|
||||
if (pinId) return String(pinId);
|
||||
const key = stripLeadSym(name).toLowerCase();
|
||||
if (!key) return null;
|
||||
const c = _idCache.get(key);
|
||||
if (c && Date.now() - c.ts < ID_TTL) return c.id;
|
||||
try {
|
||||
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
|
||||
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
|
||||
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
|
||||
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
|
||||
const id = best ? String(best.id) : null;
|
||||
_idCache.set(key, { ts: Date.now(), id });
|
||||
return id;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Albumliste eines Deezer-Künstlers, gecacht. */
|
||||
async function albenFuer(deezerId) {
|
||||
const c = _cache.get(deezerId);
|
||||
if (c && Date.now() - c.ts < TTL) return c.alben;
|
||||
try {
|
||||
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
|
||||
_cache.set(deezerId, { ts: Date.now(), alben });
|
||||
return alben;
|
||||
} catch {
|
||||
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
|
||||
return c ? c.alben : [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
|
||||
async function inHaeppchen(items, groesse, fn) {
|
||||
const raus = [];
|
||||
for (let i = 0; i < items.length; i += groesse) {
|
||||
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{id,name,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
|
||||
* @param {number} tage Zeitfenster
|
||||
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
|
||||
*/
|
||||
async function neueReleases(kuenstler, tage = 90) {
|
||||
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
|
||||
const ohneZuordnung = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
|
||||
if (!dzId) { ohneZuordnung.push(k.name); return []; }
|
||||
const alben = await albenFuer(dzId);
|
||||
return alben
|
||||
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
|
||||
.map(a => ({
|
||||
artistId: k.id,
|
||||
artistName: k.name,
|
||||
deezerArtistId: dzId,
|
||||
albumId: String(a.id),
|
||||
title: a.title || '',
|
||||
cover: coverXl(a),
|
||||
releaseDate: a.release_date,
|
||||
recordType: a.record_type || 'album',
|
||||
trackCount: a.nb_tracks ?? null,
|
||||
explicit: !!a.explicit_lyrics,
|
||||
}));
|
||||
});
|
||||
|
||||
const releases = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
|
||||
return { releases, ohneZuordnung };
|
||||
}
|
||||
|
||||
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
|
||||
@@ -115,7 +115,7 @@ const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
|
||||
async function _fetchNdSongs(ndUrl, user, pass) {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = createHash('md5').update(pass + salt).digest('hex');
|
||||
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
|
||||
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json&albumCount=0&artistCount=0`;
|
||||
const songs = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Single-track metadata resolver — the ONE place callers use to enrich a downloaded single.
|
||||
// Order: Deezer (artist-verified) → MusicBrainz (artist-verified) → nothing.
|
||||
// Returning {} is intentional and safe: the download then uses its clean fallback
|
||||
// (album_artist = song artist, album = "Singles", cover = source thumbnail, year/genre untouched).
|
||||
//
|
||||
// Both sources apply the SAME discipline — the artist must correspond — so a same-titled song by a
|
||||
// different artist can never contaminate album/cover/year again.
|
||||
|
||||
const dz = require('./deezer');
|
||||
const mb = require('./musicbrainz');
|
||||
|
||||
async function lookupTrackMeta(artist, title) {
|
||||
const d = await dz.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||
if (d && d.album) return d; // Deezer matched (artist-verified)
|
||||
|
||||
const m = await mb.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||
if (m && m.album) return m; // MusicBrainz matched (artist-verified)
|
||||
|
||||
return d || {}; // nothing convincing → clean fallback
|
||||
}
|
||||
|
||||
module.exports = { lookupTrackMeta };
|
||||
+177
-59
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const CONFIG = require('./config');
|
||||
const { log } = require('./jobs');
|
||||
const {
|
||||
sanitize, navidromeArtist,
|
||||
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||
downloadArtwork, findExistingTrack,
|
||||
} = require('./metadata');
|
||||
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||
@@ -12,11 +12,37 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||
|
||||
function buildTitleKeywords(title) {
|
||||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
||||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
||||
// reject the correct video. Channel + duration are the quality gates instead.
|
||||
if (JP_RE.test(title)) return [];
|
||||
function buildTitleKeywords(title, romaji = '') {
|
||||
// Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
|
||||
// Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
|
||||
// japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
|
||||
// Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast
|
||||
// jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026
|
||||
// ein falsches Stueck aus "HUNTER×HUNTER" herein.
|
||||
//
|
||||
// Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung.
|
||||
// Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der
|
||||
// Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen
|
||||
// ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen").
|
||||
if (JP_RE.test(title)) {
|
||||
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const alternativen = [escRe(title.trim())];
|
||||
const r = (romaji || '').trim();
|
||||
if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) {
|
||||
// Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben
|
||||
// "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich
|
||||
// weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng,
|
||||
// dass sie praktisch nie greift.
|
||||
alternativen.push(
|
||||
escRe(r)
|
||||
.replace(/ー/g, '') // kommt in lateinischen Titeln nie vor
|
||||
.replace(/\s+/g, '[ -]?')
|
||||
.replace(/ou/gi, '(?:ou|o|\u014d|oh)')
|
||||
.replace(/uu/gi, '(?:uu|u|\u016b)')
|
||||
);
|
||||
}
|
||||
return [`(?:${alternativen.join('|')})`];
|
||||
}
|
||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
|
||||
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
||||
@@ -26,14 +52,57 @@ function buildTitleKeywords(title) {
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
|
||||
const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
|
||||
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
|
||||
const result = top2.map(toPattern);
|
||||
// Numbers are the most distinctive token a title has and they are usually SHORT, so the
|
||||
// longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched
|
||||
// "777 Main Street" by a different artist. Numbers are therefore always required, on top of the
|
||||
// two longest words.
|
||||
// Ausnahme: die Jahreszahl einer Neuauflage ("… - 2020 Remaster") steht auf der Veroeffentlichung,
|
||||
// aber nie im YouTube-Titel — als Pflicht-Keyword wuerde sie den richtigen Upload ablehnen. Nur
|
||||
// wenn ein solches Qualifier-Wort daneben steht; ein Titel, der SELBST eine Jahreszahl ist
|
||||
// ("1999", "Berlin 2020"), behaelt sie.
|
||||
const RELEASE_QUALIFIER = /\b(remaster|remastered|version|edition|anniversary|mono|stereo)\b/i;
|
||||
const isEditionYear = w => /^(19|20)\d{2}$/.test(w) && RELEASE_QUALIFIER.test(title || '');
|
||||
const isRequiredNum = w => /^\d+$/.test(w) && !isEditionYear(w);
|
||||
const numbers = words.filter(isRequiredNum);
|
||||
const wordsNoNum = words.filter(w => !isRequiredNum(w));
|
||||
const top2 = wordsNoNum.sort((a, b) => b.length - a.length).slice(0, 2);
|
||||
// Uploaders abbreviate the same word the release spells out ("700 Main St" vs "700 Main Street"),
|
||||
// which rejected the official Art Track in every cascade step. Accept either spelling; \b keeps
|
||||
// the short form from matching inside another word.
|
||||
const ABBREV = { street: 'st', saint: 'st', mount: 'mt', doctor: 'dr', versus: 'vs', number: 'no' };
|
||||
const toPattern = w => {
|
||||
const abbr = ABBREV[w.toLowerCase()];
|
||||
if (abbr) return `\\b(?:${w}|${abbr})\\b`;
|
||||
return (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
|
||||
};
|
||||
const result = [...numbers.map(n => `\\b${n}\\b`), ...top2.map(toPattern)];
|
||||
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
|
||||
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
|
||||
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
|
||||
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
|
||||
// Listeneintraege und werden uebersprungen.
|
||||
//
|
||||
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
|
||||
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
|
||||
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
|
||||
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
|
||||
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
|
||||
function pickYtmVideoUrls(stdout) {
|
||||
const urls = [];
|
||||
let skipped = 0;
|
||||
for (const line of (stdout || '').split('\n')) {
|
||||
const u = line.trim();
|
||||
if (!u) continue;
|
||||
if (u.includes('watch?v=')) urls.push(u);
|
||||
else if (u.includes('/browse/')) skipped++;
|
||||
}
|
||||
return { urls, skipped };
|
||||
}
|
||||
|
||||
function buildVariantFilter(title) {
|
||||
const t = (title || '').toLowerCase();
|
||||
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
||||
@@ -50,8 +119,16 @@ function buildVariantFilter(title) {
|
||||
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
|
||||
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
|
||||
{ key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' },
|
||||
// Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine
|
||||
// Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu
|
||||
// unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek.
|
||||
// skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden
|
||||
// die Regel sonst faelschlich abschalten.
|
||||
{ key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i },
|
||||
];
|
||||
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
|
||||
// Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder
|
||||
// bewusst eine Live-Version sucht, soll sie auch bekommen.
|
||||
const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr);
|
||||
return parts.length ? parts.join(' & ') : '';
|
||||
}
|
||||
|
||||
@@ -89,11 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
|
||||
const safeTitle = sanitize(cleanTitle);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
||||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
||||
// Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe
|
||||
// dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag.
|
||||
const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist));
|
||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
||||
// Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers.
|
||||
// Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je
|
||||
// nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem
|
||||
// anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird
|
||||
// bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau
|
||||
// diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien.
|
||||
const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim());
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle)
|
||||
|| (trackArtist && trackArtist !== safeArtist
|
||||
? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle)
|
||||
: null);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
@@ -111,7 +199,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
const searchTitle = cleanTitle;
|
||||
|
||||
const variantFilter = buildVariantFilter(searchTitle);
|
||||
const titleKws = buildTitleKeywords(searchTitle);
|
||||
// Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text
|
||||
// unveraendert zurueck und die Alternative waere doppelt.
|
||||
const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : '';
|
||||
const titleKws = buildTitleKeywords(searchTitle, titleRomaji);
|
||||
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
||||
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
||||
|
||||
@@ -126,8 +217,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
|
||||
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
|
||||
// none. Used as an OR alternative to the channel check so those official uploads still pass strict.
|
||||
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||
// Fuer japanische Namen wird der KANAL nicht geprueft (Kanal-Namen stehen kaum je in Kanji),
|
||||
// das Feld `artist` der Art Tracks dagegen sehr wohl — dort steht der Originalname. Ohne diese
|
||||
// Ausnahme hatte ein japanischer Kuenstler ueberhaupt keine Identitaetspruefung.
|
||||
const artistMetaPart = (artistFirstWord.length >= 3 || (JP_RE.test(artistFirstWord) && artistFirstWord.length >= 2))
|
||||
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||
// Kanji-Kuenstler: Kanaele und Videotitel schreiben den Namen meist lateinisch. Ohne diese
|
||||
// Alternative bliebe fuer sie nur das artist-Feld in Originalschrift — und steht dort die
|
||||
// lateinische Schreibweise, faellt der richtige Upload durch.
|
||||
const artistRomajiWord = (JP_RE.test(artistFirstWord) && isTokenizerReady())
|
||||
? ((romajiForStorage(artistFirstWord) || '').split(/\s+/)
|
||||
.filter(w => w.length >= 3 && !JP_RE.test(w))
|
||||
.sort((a, b) => b.length - a.length)[0] || '')
|
||||
: '';
|
||||
const artistRomajiParts = artistRomajiWord
|
||||
? [` & channel ~= (?i)${escRe(artistRomajiWord)}`, ` & artist ~= (?i)${escRe(artistRomajiWord)}`]
|
||||
: [];
|
||||
// Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter.
|
||||
const identitaet = [artistChannelPart, artistMetaPart, ...artistRomajiParts].filter(Boolean);
|
||||
|
||||
// Use a space (not " - ") between artist and title: this matches how users type
|
||||
// in the YTM search box and ranks the exact official track first. A dash skews
|
||||
@@ -191,7 +298,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
||||
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
||||
const audioFile = hasAudio() ? expectedAudio : null;
|
||||
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
|
||||
// Direct-link download failed (e.g. YouTube 403 / bot check) — must report FAILURE, not success.
|
||||
// Previously returned { success: true }, so the job was marked "done/Fertig!" and the UI wrongly
|
||||
// said the track was added.
|
||||
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: false, failed: true, dir }; }
|
||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
|
||||
}
|
||||
|
||||
@@ -199,14 +309,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||
// result and treating its uploads as the search result set.
|
||||
//
|
||||
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
|
||||
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
|
||||
// return [] so the caller falls through to the ytsearch fallback.
|
||||
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
||||
// page processing, not just channel expansion, and would wrongly return [] always.
|
||||
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
||||
const urls = [];
|
||||
let channelExpanded = false;
|
||||
let out = '';
|
||||
const proc = spawn(CONFIG.ytdlpBin, [
|
||||
ytmUrl,
|
||||
'--flat-playlist',
|
||||
@@ -218,21 +324,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// native Songs shelf (where the actual artist track lives).
|
||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||||
proc.stdout.on('data', d => {
|
||||
const text = d.toString();
|
||||
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
|
||||
if (!channelExpanded) {
|
||||
text.split('\n').forEach(line => {
|
||||
const u = line.trim();
|
||||
if (u.includes('watch?v=')) urls.push(u);
|
||||
});
|
||||
}
|
||||
});
|
||||
proc.stdout.on('data', d => { out += d.toString(); });
|
||||
proc.on('error', () => resolve([]));
|
||||
proc.on('close', () => {
|
||||
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
|
||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
|
||||
resolve(channelExpanded ? [] : urls);
|
||||
const { urls, skipped } = pickYtmVideoUrls(out);
|
||||
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
|
||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
|
||||
resolve(urls);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,16 +345,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
durPart = '';
|
||||
}
|
||||
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
||||
// Non-strict, or no artist constraint (short / Japanese name): single filter.
|
||||
if (!strict || !artistChannelPart) return ['--match-filter', base];
|
||||
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
|
||||
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
|
||||
return [
|
||||
'--match-filter', `${base}${artistChannelPart}`,
|
||||
'--match-filter', `${base}${artistMetaPart}`,
|
||||
];
|
||||
// Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung,
|
||||
// sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch
|
||||
// wenn die Kaskade sie eigentlich lockern wollte.
|
||||
const mitKuenstler = strict || !titleKwPart;
|
||||
if (!mitKuenstler || !identitaet.length) return ['--match-filter', base];
|
||||
// Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle
|
||||
// Label-Uploads wie BANGTANTV, deren Kanal den Kuenstler nicht im Namen traegt).
|
||||
return identitaet.flatMap(teil => ['--match-filter', `${base}${teil}`]);
|
||||
};
|
||||
|
||||
// Weder Titel- noch Kuenstlermerkmal pruefbar: dann gaebe es nichts, woran sich der richtige
|
||||
// Treffer erkennen liesse, und der erste Suchtreffer passender Laenge wuerde gespeichert.
|
||||
// Lieber sauber scheitern — der Direktlink im Importer ist dafuer der vorgesehene Weg.
|
||||
if (!titleKwPart && !identitaet.length) {
|
||||
log(jobId, '⚠️ Weder Titel- noch Künstlermerkmal prüfbar — bitte Direktlink im Importer nutzen');
|
||||
return { success: false, notFound: true, dir };
|
||||
}
|
||||
|
||||
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
|
||||
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
||||
const PI_UNFILTERED = ['--playlist-items', '1'];
|
||||
@@ -284,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
|
||||
}
|
||||
// Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler
|
||||
// BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer
|
||||
// stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und
|
||||
// artist-Feld den Song tatsaechlich benennen.
|
||||
//
|
||||
// Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR
|
||||
// gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I"
|
||||
// von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend
|
||||
// eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte.
|
||||
// Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu.
|
||||
//
|
||||
// Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird
|
||||
// weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs).
|
||||
if (!hasAudio()) {
|
||||
// 3. YTM all-10 — no channel check (label channels, etc.)
|
||||
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
||||
await tryYtm(0, mf(dur, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 4. YT top-10 — no channel check
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||
}
|
||||
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
|
||||
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
|
||||
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
|
||||
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
|
||||
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all — no duration, but channel + title required
|
||||
// 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
||||
await tryYtm(0, mf(null, true, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-10 — no duration, but channel + title required
|
||||
// 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
|
||||
}
|
||||
// Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt
|
||||
// offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im
|
||||
// artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag
|
||||
// scheitert sauber — besser als still den falschen Song zu speichern.
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
||||
await tryYtm(0, mf(dur, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||
}
|
||||
} else {
|
||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
||||
// 1. YTM top-5 — strict: title + variant + channel check
|
||||
@@ -421,4 +539,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
||||
return { success: true, dir };
|
||||
}
|
||||
|
||||
module.exports = { downloadTrack, embedMetadata };
|
||||
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
|
||||
+521
-79
@@ -13,12 +13,14 @@ const { execSync, spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CONFIG = require('./lib/config');
|
||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
||||
const { sanitize, findExistingTrack, findFileByStem, albumDirFromTags, dateinamensKuenstler } = require('./lib/metadata');
|
||||
const dz = require('./lib/deezer');
|
||||
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
|
||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
||||
|
||||
@@ -92,57 +94,17 @@ function _findFileInDir(dir, basename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Last-resort: deep recursive search by title stem (handles folder renames after metadata edit).
|
||||
// Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext
|
||||
// The title stem is everything after the optional "NN - Artist - " prefix.
|
||||
// We match any audio file whose stem ends with the same title words.
|
||||
// Normalize a file stem for loose matching:
|
||||
// 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen)
|
||||
// 2. Normalize all dash variants (en-dash, em-dash) to " - "
|
||||
function _normStem(s) {
|
||||
return s
|
||||
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - "
|
||||
.replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - "
|
||||
}
|
||||
|
||||
function _findFileByStem(dir, titleStem, depth = 0) {
|
||||
if (depth > 4) return null;
|
||||
const normTitle = _normStem(titleStem);
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const found = _findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||||
if (found) return found;
|
||||
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||||
// Strip extension + leading track prefix, then normalize dashes
|
||||
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||||
const bareStem = _normStem(bare.toLowerCase());
|
||||
// Match: exact, or "Artist - Title" ending with our title stem
|
||||
if (bareStem === normTitle ||
|
||||
bareStem.endsWith(' - ' + normTitle) ||
|
||||
bareStem.endsWith(normTitle) ||
|
||||
// Release-suffix desync: Navidrome's title-derived stem ("… - recognize")
|
||||
// may lack the "- Single"/"- EP" suffix the on-disk filename still carries
|
||||
// ("… - recognize - single"), or vice-versa. Match on the structured prefix.
|
||||
bareStem.startsWith(normTitle + ' - ') ||
|
||||
normTitle.startsWith(bareStem + ' - ')) {
|
||||
return path.join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Like _findFileByStem but also tries a romaji-converted stem.
|
||||
// Die Tiefensuche nach Titelstamm liegt jetzt in lib/metadata.js (dort getestet) — sie ist der
|
||||
// letzte Ausweg, wenn Navidromes Pfad nicht existiert, und darf deshalb nicht raten.
|
||||
// Wie findFileByStem, versucht zusaetzlich einen Romaji-Stamm.
|
||||
// Handles the case where Navidrome has a Japanese path in its DB but the file
|
||||
// on disk was stored with a romaji filename by the download pipeline.
|
||||
function _findFileByStemOrRomaji(dir, stem) {
|
||||
const direct = _findFileByStem(dir, stem);
|
||||
const direct = findFileByStem(dir, stem);
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const rt = (romajiForStorage(stem) || '').toLowerCase().trim();
|
||||
if (rt && rt !== stem) return _findFileByStem(dir, rt);
|
||||
if (rt && rt !== stem) return findFileByStem(dir, rt);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -232,6 +194,106 @@ app.delete('/api/romaji-overrides/:kanji', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ──
|
||||
// Fixes same-name collisions where NovaSonic would otherwise guess the wrong Deezer artist
|
||||
// (fans / top songs / albums / image). Keyed by the Navidrome artist id.
|
||||
const _artistOverridesFile = path.join(CONFIG.dataPath, 'deezer-artist-overrides.json');
|
||||
function _loadArtistOverrides() {
|
||||
try { return JSON.parse(fs.readFileSync(_artistOverridesFile, 'utf8')); } catch { return {}; }
|
||||
}
|
||||
function _saveArtistOverrides(map) {
|
||||
try {
|
||||
fs.mkdirSync(CONFIG.dataPath, { recursive: true });
|
||||
fs.writeFileSync(_artistOverridesFile, JSON.stringify(map, null, 2), 'utf8');
|
||||
} catch (e) { console.error('Failed to save artist overrides:', e.message); }
|
||||
}
|
||||
|
||||
// GET — full map { ndArtistId: { deezerArtistId, name, image, fanCount } }
|
||||
app.get('/api/artist-overrides', (_req, res) => res.json(_loadArtistOverrides()));
|
||||
|
||||
// POST — upsert a pin { ndArtistId, deezerArtistId, name, image, fanCount }
|
||||
app.post('/api/artist-overrides', (req, res) => {
|
||||
const { ndArtistId, deezerArtistId, name, image, fanCount } = req.body || {};
|
||||
if (!ndArtistId || !deezerArtistId) return res.status(400).json({ error: 'ndArtistId and deezerArtistId required' });
|
||||
const map = _loadArtistOverrides();
|
||||
map[String(ndArtistId)] = {
|
||||
deezerArtistId: String(deezerArtistId),
|
||||
name: name || '',
|
||||
image: image || '',
|
||||
fanCount: (fanCount ?? null),
|
||||
};
|
||||
_saveArtistOverrides(map);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
|
||||
// POST /api/artist-releases { artists: [{id, name}], days }
|
||||
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
|
||||
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
|
||||
app.post('/api/artist-releases', async (req, res) => {
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
|
||||
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const pins = _loadArtistOverrides();
|
||||
const mitPin = artists
|
||||
.filter(a => a && a.id && a.name)
|
||||
.map(a => ({
|
||||
id: String(a.id),
|
||||
name: String(a.name),
|
||||
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { neueReleases } = require('./lib/releases');
|
||||
const ergebnis = await neueReleases(mitPin, days);
|
||||
res.json(ergebnis);
|
||||
} catch (e) {
|
||||
console.error('[releases]', e.message);
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/artist-concerts { artists: [{id, name}], days }
|
||||
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
|
||||
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
|
||||
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
|
||||
app.post('/api/artist-concerts', async (req, res) => {
|
||||
const key = CONFIG.ticketmasterKey;
|
||||
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
|
||||
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
// Ohne `days` gibt es KEIN Fenster — Konzerte sind ohnehin nur Zukunft, und dort will man
|
||||
// alles Angekuendigte sehen. Ein mitgeschicktes Fenster wird weiterhin beachtet.
|
||||
const roh = parseInt(req.body?.days, 10);
|
||||
const days = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 3650) : 0;
|
||||
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const sauber = artists.filter(a => a && a.id && a.name)
|
||||
.map(a => ({ id: String(a.id), name: String(a.name) }));
|
||||
|
||||
try {
|
||||
const { kommendeKonzerte } = require('./lib/konzerte');
|
||||
res.json(await kommendeKonzerte(sauber, key, days));
|
||||
} catch (e) {
|
||||
console.error('[concerts]', e.message);
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||||
const id = decodeURIComponent(req.params.ndArtistId);
|
||||
const map = _loadArtistOverrides();
|
||||
delete map[id];
|
||||
_saveArtistOverrides(map);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/romaji-convert — convert an array of strings to romaji
|
||||
app.post('/api/romaji-convert', (req, res) => {
|
||||
const { texts } = req.body;
|
||||
@@ -289,6 +351,8 @@ app.get('/api/meta/lookup', async (req, res) => {
|
||||
const { artist, title } = req.query;
|
||||
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||
try {
|
||||
// Editor "Deezer" button = Deezer only (there is a separate "MusicBrainz" button). The combined
|
||||
// Deezer→MusicBrainz→clean resolver (tm) is used by the DOWNLOAD paths, not this manual autofill.
|
||||
const meta = await dz.lookupTrackMeta(artist, title);
|
||||
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
|
||||
res.json({
|
||||
@@ -326,10 +390,38 @@ app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
|
||||
}).on('error', e => res.status(502).json({ error: e.message }));
|
||||
});
|
||||
|
||||
// Server-side events cache. Ticketmaster's free `keyword` search is flaky — for the same artist
|
||||
// it intermittently returns a partial subset (or []), so back-to-back calls disagree (web showed 7,
|
||||
// a later live call got only 2). Caching on the BACKEND (not per-client) keeps every client
|
||||
// consistent, and we PREFER THE LARGER recent result: a transient partial/empty response never
|
||||
// shrinks a good list. TTL bounds staleness (a genuinely-dropped show self-heals within an hour).
|
||||
const _eventsCache = new Map(); // artistLower → { ts, data }
|
||||
const _EVENTS_TTL = 60 * 60 * 1000; // 1h
|
||||
|
||||
app.get('/api/events/:artist', (req, res) => {
|
||||
const key = CONFIG.ticketmasterKey;
|
||||
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
|
||||
const artist = req.params.artist;
|
||||
const cacheKey = artist.toLowerCase();
|
||||
const cached = _eventsCache.get(cacheKey);
|
||||
const fresh = cached && (Date.now() - cached.ts < _EVENTS_TTL);
|
||||
|
||||
// Serve the cached list, but only when it wouldn't hide a better live result. When we fall back
|
||||
// to cache on a TM error/partial, we DON'T bump ts — so the cache still expires on schedule and
|
||||
// self-heals instead of pinning stale events forever.
|
||||
const respondWithCacheFallback = (liveEvents) => {
|
||||
if (fresh && cached.data.length > liveEvents.length) {
|
||||
console.log(`[events] ${artist}: live=${liveEvents.length} < cache=${cached.data.length} → serving cache`);
|
||||
return res.json(cached.data);
|
||||
}
|
||||
if (liveEvents.length > 0 || !fresh) {
|
||||
_eventsCache.set(cacheKey, { ts: Date.now(), data: liveEvents });
|
||||
return res.json(liveEvents);
|
||||
}
|
||||
// live empty but cache still fresh → serve cache
|
||||
return res.json(cached.data);
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); // 2024-06-08T12:00:00Z
|
||||
const url = `https://app.ticketmaster.com/discovery/v2/events.json` +
|
||||
`?keyword=${encodeURIComponent(artist)}&classificationName=music` +
|
||||
@@ -343,15 +435,18 @@ app.get('/api/events/:artist', (req, res) => {
|
||||
const data = JSON.parse(body);
|
||||
if (upstream.statusCode !== 200 || data.fault || data.errors) {
|
||||
console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200));
|
||||
return res.json([]);
|
||||
return respondWithCacheFallback([]);
|
||||
}
|
||||
const raw = data?._embedded?.events || [];
|
||||
const events = raw.map(e => {
|
||||
const venue = e._embedded?.venues?.[0] || {};
|
||||
const start = e.dates?.start || {};
|
||||
const dt = start.dateTime || (start.localDate
|
||||
// Prefer the venue-LOCAL date/time (localDate + localTime) over the UTC `dateTime` —
|
||||
// otherwise a 20:00 local show is shown as its 08:00Z UTC instant. Sent as a naive local
|
||||
// datetime string (no zone); the clients render it as-is.
|
||||
const dt = (start.localDate
|
||||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||
: null);
|
||||
: start.dateTime) || null;
|
||||
const soldOut = e.dates?.status?.code === 'offsale' ||
|
||||
e.dates?.status?.code === 'cancelled';
|
||||
return {
|
||||
@@ -364,11 +459,11 @@ app.get('/api/events/:artist', (req, res) => {
|
||||
offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [],
|
||||
};
|
||||
}).filter(e => e.datetime);
|
||||
console.log(`[events] ${artist}: ${events.length} events`);
|
||||
res.json(events);
|
||||
} catch(err) { console.error('[events] parse error:', err); res.json([]); }
|
||||
console.log(`[events] ${artist}: ${events.length} events (live)`);
|
||||
respondWithCacheFallback(events);
|
||||
} catch(err) { console.error('[events] parse error:', err); respondWithCacheFallback([]); }
|
||||
});
|
||||
}).on('error', e => { console.error('[events] request error:', e.message); res.json([]); });
|
||||
}).on('error', e => { console.error('[events] request error:', e.message); respondWithCacheFallback([]); });
|
||||
});
|
||||
|
||||
// Debug-only: returns raw Ticketmaster response so you can see exactly what the API sends back.
|
||||
@@ -391,6 +486,135 @@ app.get('/api/events-debug/:artist', (req, res) => {
|
||||
}).on('error', e => res.json({ error: e.message }));
|
||||
});
|
||||
|
||||
// ── NovaSonic Android APK distribution ──────────────────────────────────────
|
||||
// The web app offers a "download the Android app" button that points here, so co-users always get
|
||||
// the latest APK without the maintainer hand-delivering it. Two sources, tried in order:
|
||||
// 1) A FIXED Gitea release (default tag "apk-latest") whose .apk asset you overwrite each build —
|
||||
// constant ~30 MB, uploadable from anywhere. The BACKEND fetches it server-side (same LAN as
|
||||
// Gitea), so Gitea can stay internal-only and the token never reaches the browser.
|
||||
// env: GITEA_URL (internal base, e.g. http://gitea:3000), GITEA_REPO (Docker/NovaSonic),
|
||||
// GITEA_TOKEN (read token; only needed for a private repo), GITEA_APK_TAG (default apk-latest)
|
||||
// 2) Fallback: a local file in the writable data volume:
|
||||
// docker cp app-release.apk novasonic-backend:/data/novasonic.apk
|
||||
// (optional /data/novasonic.version.txt with a version string)
|
||||
const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk');
|
||||
const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt');
|
||||
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
|
||||
const GITEA_REPO = process.env.GITEA_REPO || '';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||
const GITEA_APK_TAG = process.env.GITEA_APK_TAG || 'apk-latest';
|
||||
|
||||
function _giteaApiJson(pathAndQuery) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!GITEA_URL || !GITEA_REPO) return reject(new Error('gitea not configured'));
|
||||
const u = new URL(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}${pathAndQuery}`);
|
||||
const lib = u.protocol === 'https:' ? https : http;
|
||||
const headers = { 'Accept': 'application/json', 'User-Agent': 'NovaSonic' };
|
||||
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
|
||||
lib.get(u, { headers }, r => {
|
||||
let body = '';
|
||||
r.on('data', d => body += d);
|
||||
r.on('end', () => {
|
||||
if (r.statusCode < 200 || r.statusCode >= 300) return reject(new Error(`gitea ${r.statusCode}`));
|
||||
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Gitea baut seine browser_download_url aus der eigenen ROOT_URL — die kann eine ganz andere Adresse
|
||||
// sein als die, unter der das Backend Gitea erreicht (interne IP vs. Reverse-Proxy-Domain). Deshalb
|
||||
// Schema+Host+Port immer durch GITEA_URL ersetzen und nur den Pfad übernehmen.
|
||||
function _viaGiteaHost(urlStr) {
|
||||
if (!urlStr || !GITEA_URL) return urlStr;
|
||||
try {
|
||||
const base = new URL(GITEA_URL);
|
||||
const u = new URL(urlStr);
|
||||
if (u.origin === base.origin) return urlStr;
|
||||
return new URL(u.pathname + u.search, base).toString();
|
||||
} catch { return urlStr; }
|
||||
}
|
||||
|
||||
// The .apk asset of the fixed release, or null if unavailable.
|
||||
async function _giteaApkAsset() {
|
||||
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
|
||||
const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || ''));
|
||||
if (!asset) return null;
|
||||
// The publish script writes "versionCode=NN" into the release body so the in-app updater has a
|
||||
// reliable integer to compare against BuildConfig.VERSION_CODE.
|
||||
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
|
||||
return {
|
||||
size: asset.size,
|
||||
url: _viaGiteaHost(asset.browser_download_url),
|
||||
version: rel.name || rel.tag_name || null,
|
||||
versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null,
|
||||
updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function _serveLocalApk(res) {
|
||||
if (res.headersSent) return;
|
||||
fs.stat(APK_PATH, (err) => {
|
||||
// Ohne lokale Datei UND ohne erreichbares Gitea-Asset: sagen, welcher der beiden Wege fehlt,
|
||||
// sonst sucht man die APK an der falschen Stelle (die Logs oben nennen den Grund).
|
||||
if (err) return res.status(404).json({
|
||||
error: (GITEA_URL && GITEA_REPO)
|
||||
? `APK-Release nicht erreichbar (${GITEA_URL}, Tag ${GITEA_APK_TAG}) und keine lokale Datei unter ${APK_PATH}`
|
||||
: 'Keine APK hinterlegt',
|
||||
});
|
||||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||||
res.sendFile(APK_PATH);
|
||||
});
|
||||
}
|
||||
|
||||
// Stream a Gitea attachment to the client, following redirects, falling back to the local file.
|
||||
function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
|
||||
const u = new URL(urlStr);
|
||||
const lib = u.protocol === 'https:' ? https : http;
|
||||
const headers = { 'User-Agent': 'NovaSonic' };
|
||||
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
|
||||
lib.get(u, { headers }, up => {
|
||||
if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) {
|
||||
up.resume();
|
||||
return _streamApkFromUrl(_viaGiteaHost(new URL(up.headers.location, u).toString()), res, redirectsLeft - 1);
|
||||
}
|
||||
if (up.statusCode !== 200) {
|
||||
up.resume();
|
||||
console.warn(`[apk] Gitea-Asset lieferte HTTP ${up.statusCode} (${u.origin}) — fallback auf ${APK_PATH}`);
|
||||
return _serveLocalApk(res);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||||
if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']);
|
||||
up.pipe(res);
|
||||
}).on('error', (e) => {
|
||||
console.warn(`[apk] Gitea-Asset nicht erreichbar (${u.origin}): ${e.message} — fallback auf ${APK_PATH}`);
|
||||
_serveLocalApk(res);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/apk/info', async (req, res) => {
|
||||
try {
|
||||
const a = await _giteaApkAsset();
|
||||
if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, versionCode: a.versionCode, source: 'gitea' });
|
||||
} catch { /* fall through to local */ }
|
||||
fs.stat(APK_PATH, (err, st) => {
|
||||
if (err) return res.json({ available: false });
|
||||
let version = null, versionCode = null;
|
||||
try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {}
|
||||
try { versionCode = parseInt(fs.readFileSync(path.join(CONFIG.dataPath, 'novasonic.versioncode.txt'), 'utf8').trim(), 10) || null; } catch {}
|
||||
res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, versionCode, source: 'local' });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/apk', async (req, res) => {
|
||||
let asset = null;
|
||||
try { asset = await _giteaApkAsset(); } catch { /* fall through to local */ }
|
||||
if (asset?.url) return _streamApkFromUrl(asset.url, res);
|
||||
_serveLocalApk(res);
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok', ytdlpVersion,
|
||||
@@ -524,7 +748,7 @@ app.post('/api/youtube/resolve', async (req, res) => {
|
||||
const title = e.title || '';
|
||||
const sourceUrl = e.webpage_url || url;
|
||||
let meta = {};
|
||||
try { meta = await dz.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
try { meta = await tm.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
|
||||
const artist = meta.artist || rawArtist;
|
||||
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
|
||||
@@ -583,7 +807,7 @@ app.post('/api/download', async (req, res) => {
|
||||
const searchArtist = artist.split(',')[0].trim();
|
||||
if (!meta.album) {
|
||||
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
|
||||
const extra = await dz.lookupTrackMeta(searchArtist, title);
|
||||
const extra = await tm.lookupTrackMeta(searchArtist, title);
|
||||
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
|
||||
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
|
||||
} else {
|
||||
@@ -610,6 +834,8 @@ app.post('/api/download', async (req, res) => {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||
} else if (result?.notFound) {
|
||||
updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' });
|
||||
} else if (result?.failed) {
|
||||
updateJob(jobId, { status: 'error', message: 'Download fehlgeschlagen — YouTube hat den Abruf blockiert (403).' });
|
||||
} else {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
|
||||
}
|
||||
@@ -775,7 +1001,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
try {
|
||||
const { s, t } = mkTok();
|
||||
const r = await fetch(
|
||||
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(15000) }
|
||||
);
|
||||
const d = await r.json();
|
||||
@@ -897,7 +1123,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
// Trigger Navidrome rescan
|
||||
try {
|
||||
const { s, t } = mkTok();
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {}
|
||||
|
||||
const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : '';
|
||||
@@ -1036,13 +1262,14 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
|
||||
app.get('/api/song-sorttags', async (req, res) => {
|
||||
const { ndUrl, user, pass, songId } = req.query;
|
||||
if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
|
||||
let filePath;
|
||||
let filePath, ndSong;
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) });
|
||||
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(8000) });
|
||||
const nd = await ndRes.json();
|
||||
filePath = nd?.['subsonic-response']?.song?.path;
|
||||
ndSong = nd?.['subsonic-response']?.song;
|
||||
filePath = ndSong?.path;
|
||||
if (!filePath) return res.status(404).json({ error: 'Song not found' });
|
||||
} catch (e) { return res.status(502).json({ error: e.message }); }
|
||||
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
|
||||
@@ -1051,9 +1278,11 @@ app.get('/api/song-sorttags', async (req, res) => {
|
||||
const rebased = _rebasePath(filePath, CONFIG.musicPath);
|
||||
const rebasedDir = rebased ? path.dirname(rebased) : null;
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = rebased
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(rebasedDir || path.dirname(filePath), fileName)
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
if (found) filePath = found; else return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
@@ -1083,7 +1312,7 @@ async function _ndApi(ndUrl, user, pass, method, extra = '') {
|
||||
const s = Math.random().toString(36).slice(2, 10);
|
||||
const t = crypto.createHash('md5').update(pass + s).digest('hex');
|
||||
const r = await fetch(
|
||||
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`,
|
||||
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json${extra}`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
return r.json();
|
||||
@@ -1135,6 +1364,43 @@ async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) {
|
||||
}
|
||||
|
||||
// ─── METADATA EDIT ───────────────────────────────────────────────────────────
|
||||
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
|
||||
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
|
||||
async function _readTags(file) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
|
||||
let buf = '';
|
||||
proc.stdout.on('data', d => buf += d);
|
||||
proc.on('error', () => resolve({}));
|
||||
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
|
||||
});
|
||||
}
|
||||
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
|
||||
// Steckbrief einer Datei fuer den Dublettenvergleich: Groesse, Laenge, Titel/Kuenstler.
|
||||
// Zwei Kopien desselben Songs unterscheiden sich fast immer nur in der Groesse (andere Bitrate),
|
||||
// nie nennenswert in der Laenge — deshalb ist die Laenge das aussagekraeftige Feld.
|
||||
async function _dateiSteckbrief(file) {
|
||||
let groesse = null;
|
||||
try { groesse = fs.statSync(file).size; } catch {}
|
||||
const dauer = await new Promise((resolve) => {
|
||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', file]);
|
||||
let buf = '';
|
||||
proc.stdout.on('data', d => buf += d);
|
||||
proc.on('error', () => resolve(null));
|
||||
proc.on('close', () => {
|
||||
try { resolve(Math.round(parseFloat(JSON.parse(buf)?.format?.duration)) || null); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
const tags = await _readTags(file);
|
||||
return {
|
||||
pfad: file, groesse, dauer,
|
||||
titel: tags.title || tags.TITLE || null,
|
||||
artist: tags.artist || tags.ARTIST || null,
|
||||
album: tags.album || tags.ALBUM || null,
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/edit-metadata', async (req, res) => {
|
||||
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
|
||||
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl,
|
||||
@@ -1157,7 +1423,7 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`;
|
||||
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`;
|
||||
const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) });
|
||||
const ndData = await ndRes.json();
|
||||
ndSong = ndData?.['subsonic-response']?.song;
|
||||
@@ -1186,16 +1452,45 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
// but Navidrome's index is stale and still points to the old name.
|
||||
const originalDir = path.dirname(filePath);
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
// Schraegstrich im Kuenstler-/Albumnamen ("K/DA", "POP/STARS"): Navidromes Tag-Pfad macht
|
||||
// daraus eine Verzeichnisebene, auf der Platte steht "_". Den Ordner deshalb aus den Tags
|
||||
// nachbauen und DORT suchen — das ist gezielt und geht jeder Aehnlichkeitssuche vor.
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = rebased
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(rebasedDir || originalDir, fileName)
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|
||||
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
|
||||
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
|
||||
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|
||||
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
|
||||
if (found) { filePath = found; }
|
||||
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
|
||||
if (!found) {
|
||||
return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
|
||||
}
|
||||
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
|
||||
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
|
||||
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
|
||||
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
|
||||
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
|
||||
const kandidatTags = await _readTags(found);
|
||||
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
|
||||
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
|
||||
const sollTitel = _tagNorm(ndSong?.title);
|
||||
const sollArtist = _tagNorm(ndSong?.artist);
|
||||
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
|
||||
const artistPasst = !sollArtist || !istArtist ||
|
||||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
|
||||
if (!titelPasst || !artistPasst) {
|
||||
return res.status(409).json({
|
||||
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
|
||||
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
|
||||
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
|
||||
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
|
||||
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
|
||||
});
|
||||
}
|
||||
filePath = found;
|
||||
}
|
||||
|
||||
// Resolve artwork: base64 > URL
|
||||
@@ -1274,19 +1569,33 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
|
||||
// ── Post-save: rename file and/or move to correct artist/album folder ──
|
||||
let currentFile = filePath;
|
||||
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
|
||||
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
|
||||
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
|
||||
const warnungen = [];
|
||||
// Faellt beim Verschieben eine gleichnamige Datei am Ziel auf, ist das bereits der
|
||||
// Dublettenfall. Statt nur zu melden, werden beide Dateien verglichen und die Angaben
|
||||
// mitgeschickt, damit die Oberflaeche das Aufraeumen direkt anbieten kann.
|
||||
let dublette = null;
|
||||
|
||||
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
||||
// Use original names (may be Japanese); romaji goes only into sort tags.
|
||||
if (title) {
|
||||
const safeTitle = sanitize(title);
|
||||
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
|
||||
const fileArtist = dateinamensKuenstler(normalArtist, ndSong?.artist);
|
||||
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
||||
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
||||
const newPath = path.join(path.dirname(currentFile), newName);
|
||||
if (newPath !== currentFile && !fs.existsSync(newPath)) {
|
||||
if (newPath !== currentFile && fs.existsSync(newPath)) {
|
||||
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
|
||||
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
|
||||
} else if (newPath !== currentFile) {
|
||||
try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
|
||||
catch (e) { console.error('[edit-metadata] file rename:', e.message); }
|
||||
catch (e) {
|
||||
console.error('[edit-metadata] file rename:', e.message);
|
||||
warnungen.push(`Umbenennen fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1610,23 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
const destFile = path.join(newDir, path.basename(currentFile));
|
||||
if (!fs.existsSync(destFile)) {
|
||||
if (fs.existsSync(destFile)) {
|
||||
const [ziel, hier] = await Promise.all([
|
||||
_dateiSteckbrief(destFile), _dateiSteckbrief(currentFile),
|
||||
]);
|
||||
// Gleiche Laenge (± 2 s) UND gleicher Titel: dann sind es zwei Kopien desselben Songs
|
||||
// und die ueberzaehlige kann weg. Sonst nur melden — Loeschen ist unwiderruflich.
|
||||
const laengeGleich = ziel.dauer != null && hier.dauer != null &&
|
||||
Math.abs(ziel.dauer - hier.dauer) <= 2;
|
||||
const titelGleich = _tagNorm(ziel.titel) === _tagNorm(hier.titel);
|
||||
dublette = {
|
||||
behalten: ziel, // liegt schon am richtigen Platz
|
||||
ueberzaehlig: hier, // die gerade bearbeitete Datei im alten Ordner
|
||||
gleich: !!(laengeGleich && titelGleich),
|
||||
};
|
||||
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
|
||||
`"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
|
||||
} else {
|
||||
fs.renameSync(currentFile, destFile);
|
||||
currentFile = destFile;
|
||||
}
|
||||
@@ -1328,20 +1653,42 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
const salt2 = Math.random().toString(36).slice(2, 10);
|
||||
const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex');
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {}
|
||||
|
||||
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID
|
||||
if (affectedPlaylists.length > 0) {
|
||||
// Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
|
||||
// was an der alten ID hing, waere sonst weg: die Playlist-Zugehoerigkeit — und der Stern, der
|
||||
// bisher stillschweigend verlorenging. Beides wird auf die neue ID uebertragen.
|
||||
const warStarred = !!ndSong?.starred;
|
||||
if (affectedPlaylists.length > 0 || warStarred) {
|
||||
const effectiveTitle = title || ndSong?.title || '';
|
||||
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
|
||||
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
|
||||
if (newSong && newSong.id !== songId) {
|
||||
if (affectedPlaylists.length > 0) {
|
||||
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
|
||||
}
|
||||
if (warStarred) {
|
||||
// Der Stern ist in Navidrome pro Nutzer gespeichert; uebertragen laesst sich nur der des
|
||||
// bearbeitenden Nutzers — fremde Markierungen sind ueber die Subsonic-API weder lesbar
|
||||
// noch setzbar. Schlaegt es fehl, bleibt es bei einer Warnung statt eines Abbruchs.
|
||||
try {
|
||||
const st = await _ndApi(ndUrl, user, pass, 'star', `&id=${encodeURIComponent(newSong.id)}`);
|
||||
if (st?.['subsonic-response']?.status !== 'ok') throw new Error('star abgelehnt');
|
||||
} catch (e) {
|
||||
warnungen.push(`Favorit konnte nicht uebertragen werden (${e.message}) — der Song muss ` +
|
||||
`erneut markiert werden.`);
|
||||
}
|
||||
}
|
||||
} else if (warStarred && !newSong) {
|
||||
warnungen.push('Der Song wurde nach dem Umbenennen nicht wiedergefunden — die Markierung ' +
|
||||
'als Favorit konnte nicht uebertragen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
// Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
|
||||
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
|
||||
res.json({ ok: true, file: currentFile, warnings: warnungen, duplicate: dublette });
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
|
||||
if (artFile) try { fs.unlinkSync(artFile); } catch {}
|
||||
@@ -1360,16 +1707,17 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
|
||||
|
||||
// Resolve the file path from Navidrome
|
||||
let filePath;
|
||||
let filePath, ndSong;
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndRes = await fetch(
|
||||
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
const ndData = await ndRes.json();
|
||||
filePath = ndData?.['subsonic-response']?.song?.path;
|
||||
ndSong = ndData?.['subsonic-response']?.song;
|
||||
filePath = ndSong?.path;
|
||||
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
|
||||
} catch (e) {
|
||||
return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` });
|
||||
@@ -1380,14 +1728,36 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const fileName = path.basename(filePath);
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = _rebasePath(filePath, CONFIG.musicPath)
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(path.dirname(filePath), fileName)
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
if (found) filePath = found;
|
||||
else return res.status(404).json({
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|
||||
|| findExistingTrack(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist || '', ndSong?.title || '');
|
||||
if (!found) return res.status(404).json({
|
||||
error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`,
|
||||
});
|
||||
// Derselbe Abgleich wie im Metadaten-Editor, und hier WICHTIGER: der Treffer stammt aus einer
|
||||
// Aehnlichkeitssuche, und geloescht wird unwiderruflich. Passen die Tags der gefundenen Datei
|
||||
// nicht zu dem, was Navidrome fuer diesen Song meldet, wird abgebrochen statt geraten.
|
||||
const delTags = await _readTags(found);
|
||||
const istTitel = _tagNorm(delTags.title || delTags.TITLE);
|
||||
const istArtist = _tagNorm(delTags.artist || delTags.ARTIST);
|
||||
const sollTitel = _tagNorm(ndSong?.title);
|
||||
const sollArtist = _tagNorm(ndSong?.artist);
|
||||
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
|
||||
const artistPasst = !sollArtist || !istArtist ||
|
||||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
|
||||
if (!titelPasst || !artistPasst) {
|
||||
return res.status(409).json({
|
||||
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
|
||||
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
|
||||
`${delTags.artist || '?'} – ${delTags.title || '?'}" (${found}). ` +
|
||||
`Es wird nichts geloescht — bitte die Dubletten aufraeumen.`,
|
||||
});
|
||||
}
|
||||
filePath = found;
|
||||
}
|
||||
|
||||
// Safety: file must be inside the music root and must be an audio file
|
||||
@@ -1429,7 +1799,7 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
const s2 = Math.random().toString(36).slice(2, 10);
|
||||
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
|
||||
await fetch(
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
} catch {}
|
||||
@@ -1437,6 +1807,78 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── DUBLETTE AUFLOESEN ──────────────────────────────────────────────────────
|
||||
// Nach einem abgelehnten Verschieben ("am Ziel liegt schon eine Datei") liegt derselbe Song
|
||||
// zweimal auf der Platte. Dieser Endpunkt loescht die ueberzaehlige Kopie — aber nur, wenn er
|
||||
// selbst nachgeprueft hat, dass es wirklich eine Dublette ist. Er nimmt Pfade entgegen, deshalb
|
||||
// prueft er strenger als das Loeschen per songId:
|
||||
// 1. beide Pfade liegen im Musikordner und sind Audiodateien
|
||||
// 2. beide existieren
|
||||
// 3. gleiche Laenge (± 2 s) UND gleicher Titel-Tag — sonst sind es zwei verschiedene Aufnahmen
|
||||
// Erst dann faellt die zu loeschende Datei.
|
||||
app.post('/api/resolve-duplicate', async (req, res) => {
|
||||
const { ndUrl, user, pass, behalten, ueberzaehlig } = req.body;
|
||||
if (!ndUrl || !user || !pass || !behalten || !ueberzaehlig)
|
||||
return res.status(400).json({ error: 'ndUrl, user, pass, behalten, ueberzaehlig required' });
|
||||
if (behalten === ueberzaehlig)
|
||||
return res.status(400).json({ error: 'Beide Pfade sind identisch' });
|
||||
|
||||
const realRoot = path.resolve(CONFIG.musicPath);
|
||||
const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
|
||||
const pruefe = (pfad, name) => {
|
||||
const abs = path.resolve(pfad);
|
||||
if (!abs.startsWith(rootSlash)) return `${name}: Pfad liegt ausserhalb des Musikordners`;
|
||||
if (!AUDIO_EXT_RE.test(path.extname(abs))) return `${name}: keine Audiodatei`;
|
||||
if (!fs.existsSync(abs)) return `${name}: existiert nicht mehr`;
|
||||
return null;
|
||||
};
|
||||
const fehler = pruefe(behalten, 'behalten') || pruefe(ueberzaehlig, 'ueberzaehlig');
|
||||
if (fehler) return res.status(400).json({ error: fehler });
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
_dateiSteckbrief(path.resolve(behalten)), _dateiSteckbrief(path.resolve(ueberzaehlig)),
|
||||
]);
|
||||
const laengeGleich = a.dauer != null && b.dauer != null && Math.abs(a.dauer - b.dauer) <= 2;
|
||||
const titelGleich = _tagNorm(a.titel) === _tagNorm(b.titel);
|
||||
if (!laengeGleich || !titelGleich) {
|
||||
return res.status(409).json({
|
||||
error: `Das sind keine zwei Kopien desselben Songs — "${a.titel || '?'}" (${a.dauer ?? '?'} s) ` +
|
||||
`gegen "${b.titel || '?'}" (${b.dauer ?? '?'} s). Es wird nichts geloescht.`,
|
||||
});
|
||||
}
|
||||
|
||||
const ziel = path.resolve(ueberzaehlig);
|
||||
try { fs.unlinkSync(ziel); }
|
||||
catch (e) { return res.status(500).json({ error: `Loeschen fehlgeschlagen: ${e.message}` }); }
|
||||
|
||||
// Leere Album- und Kuenstlerordner aufraeumen — gleiche Tiefenbegrenzung wie beim Song-Loeschen.
|
||||
const albumDir = path.dirname(ziel);
|
||||
const artistDir = path.dirname(albumDir);
|
||||
if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) {
|
||||
try {
|
||||
if (fs.readdirSync(albumDir).filter(f => !f.startsWith('.')).length === 0) {
|
||||
fs.rmdirSync(albumDir);
|
||||
if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) {
|
||||
try {
|
||||
if (fs.readdirSync(artistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(artistDir);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const s2 = Math.random().toString(36).slice(2, 10);
|
||||
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
|
||||
await fetch(
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
} catch {}
|
||||
|
||||
res.json({ ok: true, geloescht: ziel, verbleibt: path.resolve(behalten) });
|
||||
});
|
||||
|
||||
// ─── ABS PROXY ───────────────────────────────────────────────────────────────
|
||||
// Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser.
|
||||
// The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// Regressionsprobe fuer den Download-Filter.
|
||||
//
|
||||
// Warum es das gibt: jede Verschaerfung am Filter, die einen Fehlgriff verhindert, kann eine ganze
|
||||
// KLASSE richtiger Treffer abweisen — und der naechste Bericht kommt dann aus der anderen Richtung.
|
||||
// Damit der Umbau nicht im Kreis laeuft, wird vor jeder Aenderung gegen bekannte Faelle geprueft.
|
||||
//
|
||||
// Was es tut: holt fuer jeden Fall EINMAL die echten Suchtreffer (yt-dlp, mit Metadaten, damit auch
|
||||
// das `artist`-Feld der Art Tracks vorliegt) und spielt dann die Kaskadenstufen lokal durch —
|
||||
// in der ALTEN und der NEUEN Reihenfolge. Gemeldet wird, welcher Treffer jeweils gewinnt.
|
||||
//
|
||||
// WICHTIG: beide Quellen werden abgefragt, YT MUSIC ZUERST. Die erste Fassung dieser Probe sah nur
|
||||
// die gewoehnliche YouTube-Suche und meldete deshalb fuer "Get Lucky" einen Grammy-Mitschnitt als
|
||||
// Treffer — in Wahrheit liefert YT Music dort die offizielle Tonspur an erster Stelle, und genau
|
||||
// die laedt die Kaskade. Eine Probe, die eine Stufe auslaesst, erfindet Fehler.
|
||||
//
|
||||
// docker run --rm --network host --entrypoint node <backend-image> /app/scripts/filter-probe.mjs
|
||||
//
|
||||
// Die Filterbedingungen sind hier nachgebaut, nicht importiert: geprueft werden soll, was yt-dlp
|
||||
// mit --match-filter tut, und das ist ein Ausdruck ueber dieselben Felder.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildTitleKeywords, buildVariantFilter } = require('../lib/ytdlp.js');
|
||||
|
||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||
|
||||
// ── Faelle ────────────────────────────────────────────────────────────────────
|
||||
// Jeder Fall nennt, was herauskommen MUSS (erwartetFragment) — ein Stueck des Kanalnamens oder
|
||||
// Titels des richtigen Uploads. Faelle mit `darfNicht` nennen zusaetzlich einen bekannten Fehlgriff.
|
||||
const FAELLE = [
|
||||
{
|
||||
name: 'TPR — Dearly Beloved (Deezer kennt TPR nicht, Dauer gehoert zu anderer Fassung)',
|
||||
artist: 'TPR', titel: 'Dearly Beloved', dauer: 230,
|
||||
erwartetFragment: 'TPR', darfNicht: 'LSD',
|
||||
},
|
||||
{
|
||||
name: 'BTS — Pied Piper (Label-Kanal BANGTANTV, Kuenstler nur im artist-Feld)',
|
||||
artist: 'BTS', titel: 'Pied Piper', dauer: 246,
|
||||
erwartetFragment: 'Pied Piper',
|
||||
},
|
||||
{
|
||||
name: 'Yoshihisa Hirano — 銀髪の少年 (japanischer Titel, Soundtrack-Album)',
|
||||
artist: 'Yoshihisa Hirano', titel: '銀髪の少年', dauer: 129,
|
||||
// Der richtige Upload heisst romanisiert "Ginpatsu no Shonen" — ohne das lange o.
|
||||
// Genau dafuer ist das tolerante Umschrift-Muster da.
|
||||
romaji: 'Ginpatsu No Shounen', erwartetFragment: 'Hirano',
|
||||
},
|
||||
{
|
||||
name: 'Rising Insane — DEATHRACE (normaler westlicher Fall)',
|
||||
artist: 'Rising Insane', titel: 'DEATHRACE', dauer: 216,
|
||||
erwartetFragment: 'Rising Insane',
|
||||
},
|
||||
{
|
||||
name: 'Daft Punk — Get Lucky (Klassiker, viele Coverversionen)',
|
||||
artist: 'Daft Punk', titel: 'Get Lucky', dauer: 369,
|
||||
erwartetFragment: 'Daft Punk',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Suchtreffer holen ─────────────────────────────────────────────────────────
|
||||
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
|
||||
|
||||
/** YT-Music-Suche: erst die URLs der Songs-Leiste, dann deren Metadaten. */
|
||||
async function sucheYtm(query, n) {
|
||||
const url = `https://music.youtube.com/search?q=${encodeURIComponent(query)}¶ms=${YTM_SONGS_PARAM}`;
|
||||
const urls = await new Promise(resolve => {
|
||||
const p = spawn('yt-dlp', [
|
||||
url, '--flat-playlist', '--playlist-items', `1-${n}`, '--print', 'webpage_url',
|
||||
'--no-warnings', '--extractor-args', 'youtube:player_client=web_music,default',
|
||||
]);
|
||||
let aus = '';
|
||||
p.stdout.on('data', d => aus += d.toString());
|
||||
p.on('error', () => resolve([]));
|
||||
p.on('close', () => resolve(aus.split('\n').map(z => z.trim()).filter(z => z.includes('watch?v='))));
|
||||
});
|
||||
if (!urls.length) return [];
|
||||
return metadaten(urls);
|
||||
}
|
||||
|
||||
function suche(query, n) {
|
||||
return metadaten([`ytsearch${n}:${query}`]);
|
||||
}
|
||||
|
||||
/** Metadaten zu einer Liste von URLs (oder einer Suchangabe) holen. */
|
||||
function metadaten(ziele) {
|
||||
return new Promise(resolve => {
|
||||
const felder = '%(duration)s%(channel)s%(artist)s%(title)s';
|
||||
const p = spawn('yt-dlp', [
|
||||
...ziele, '--simulate', '--no-warnings', '--ignore-errors',
|
||||
'--print', felder,
|
||||
]);
|
||||
let aus = '';
|
||||
p.stdout.on('data', d => aus += d.toString());
|
||||
p.on('error', () => resolve([]));
|
||||
p.on('close', () => resolve(
|
||||
aus.split('\n').filter(Boolean).map(z => {
|
||||
const [dauer, kanal, kuenstler, titel] = z.split('');
|
||||
return {
|
||||
dauer: Number(dauer) || 0,
|
||||
kanal: kanal === 'NA' ? '' : kanal,
|
||||
kuenstler: kuenstler === 'NA' ? '' : kuenstler,
|
||||
titel: titel || '',
|
||||
};
|
||||
})
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filterbedingungen, wie mf() sie baut ──────────────────────────────────────
|
||||
function pruefer(fall) {
|
||||
const kws = buildTitleKeywords(fall.titel, fall.romaji || '');
|
||||
const variant = buildVariantFilter(fall.titel);
|
||||
const ersterK = fall.artist.split(/[\s,/]+/)[0];
|
||||
const rand = Math.max(10, Math.round(fall.dauer * 0.10));
|
||||
const fenster = { min: Math.max(30, fall.dauer - rand), max: fall.dauer + rand };
|
||||
|
||||
const titelPasst = k =>
|
||||
kws.every(muster => new RegExp(muster, 'i').test(k.titel));
|
||||
// buildVariantFilter liefert einen yt-dlp-Ausdruck: mehrere "title !~= (?i)MUSTER", mit & verknuepft.
|
||||
// Hier werden die Muster herausgeloest und als "darf auf KEINES passen" geprueft.
|
||||
const verboten = [...(variant || '').matchAll(/title !~= \(\?i\)([^&]+)/g)]
|
||||
.map(m => m[1].trim()).filter(Boolean);
|
||||
const variantePasst = k => !verboten.some(m => new RegExp(m, 'i').test(k.titel));
|
||||
const kuenstlerPasst = k => {
|
||||
if (ersterK.length < 3 && !JP_RE.test(ersterK)) return true; // kein Merkmal -> keine Huerde
|
||||
const re = new RegExp(ersterK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
||||
return re.test(k.kanal) || re.test(k.kuenstler);
|
||||
};
|
||||
const imFenster = k => k.dauer >= fenster.min && k.dauer <= fenster.max;
|
||||
const unterKappe = k => k.dauer > 0 && k.dauer < fall.dauer * 2;
|
||||
|
||||
return {
|
||||
fenster,
|
||||
// streng: Dauer + Titel + Variante + Kuenstler
|
||||
streng: k => imFenster(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
|
||||
// Dauer behalten, Kuenstler fallen lassen
|
||||
ohneKuenstler: k => imFenster(k) && titelPasst(k) && variantePasst(k),
|
||||
// Kuenstler behalten, Dauer lockern (2x-Kappe bleibt)
|
||||
ohneDauer: k => unterKappe(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
|
||||
};
|
||||
}
|
||||
|
||||
const ersterTreffer = (kandidaten, pruefung) => kandidaten.find(pruefung) || null;
|
||||
|
||||
function kaskade(ytm, yt, p, reihenfolge) {
|
||||
const stufen = reihenfolge === 'neu'
|
||||
? [['1/2 streng', p.streng], ['3/4 ohne Dauer, mit Künstler', p.ohneDauer], ['5/6 mit Dauer, ohne Künstler', p.ohneKuenstler]]
|
||||
: [['1/2 streng', p.streng], ['3/4 mit Dauer, ohne Künstler', p.ohneKuenstler], ['5/6 ohne Dauer, mit Künstler', p.ohneDauer]];
|
||||
for (const [stufe, pruefung] of stufen) {
|
||||
// Jede Stufe fragt erst YT Music, dann die gewoehnliche YouTube-Suche — so laeuft die Kaskade.
|
||||
const t = ersterTreffer(ytm, pruefung) || ersterTreffer(yt, pruefung);
|
||||
if (t) return { stufe: `${stufe}${ytm.includes(t) ? ' · YTM' : ' · YT'}`, treffer: t };
|
||||
}
|
||||
return { stufe: 'kein Treffer', treffer: null };
|
||||
}
|
||||
|
||||
const zeig = t => t ? `${String(t.dauer).padStart(4)}s | ${t.kanal || '?'} | ${t.titel}` : '— nichts —';
|
||||
|
||||
(async () => {
|
||||
let abweichungen = 0, fehler = 0;
|
||||
for (const fall of FAELLE) {
|
||||
const query = `${fall.artist} ${fall.titel}`;
|
||||
process.stdout.write(`\n▶ ${fall.name}\n Suche: "${query}" `);
|
||||
const ytm = await sucheYtm(query, 10);
|
||||
const yt = await suche(query, 10);
|
||||
console.log(`(YTM ${ytm.length}, YouTube ${yt.length})`);
|
||||
if (!ytm.length && !yt.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; }
|
||||
|
||||
const p = pruefer(fall);
|
||||
console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`);
|
||||
const alt = kaskade(ytm, yt, p, 'alt');
|
||||
const neu = kaskade(ytm, yt, p, 'neu');
|
||||
console.log(` ALT [${alt.stufe}] ${zeig(alt.treffer)}`);
|
||||
console.log(` NEU [${neu.stufe}] ${zeig(neu.treffer)}`);
|
||||
|
||||
const passt = t => t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.erwartetFragment.toLowerCase());
|
||||
const schlecht = t => fall.darfNicht && t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.darfNicht.toLowerCase());
|
||||
|
||||
if (!neu.treffer && alt.treffer) { console.log(' ❌ REGRESSION: neu findet nichts, alt schon'); fehler++; }
|
||||
else if (schlecht(neu.treffer)) { console.log(' ❌ neu trifft weiterhin den bekannten Fehlgriff'); fehler++; }
|
||||
else if (!passt(neu.treffer)) { console.log(` ⚠️ neu trifft nicht "${fall.erwartetFragment}"`); fehler++; }
|
||||
else { console.log(' ✅ neu in Ordnung'); }
|
||||
if (zeig(alt.treffer) !== zeig(neu.treffer)) abweichungen++;
|
||||
}
|
||||
console.log(`\n${FAELLE.length} Fälle, ${abweichungen} Unterschied(e) zwischen alt und neu, ${fehler} Problem(e)`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
})();
|
||||
@@ -65,10 +65,21 @@ test('pickTrackMatch: strict match on artist + title', () => {
|
||||
assert.equal(m, results[1]);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: title-only fallback for non-Japanese when artist differs', () => {
|
||||
const results = [{ title: 'Levitating', artist: { name: 'Some Cover Act' } }];
|
||||
const m = dz.pickTrackMatch(results, 'Dua Lipa', 'Levitating');
|
||||
assert.equal(m, results[0]); // ASCII title → title-only fallback allowed
|
||||
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
|
||||
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
|
||||
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
|
||||
const results = [
|
||||
{ title: 'Memories', artist: { name: 'Rab Noakes' } },
|
||||
{ title: 'Memories', artist: { name: 'Maroon 5' } },
|
||||
];
|
||||
assert.equal(dz.pickTrackMatch(results, '☆NoAki☆', 'Memories'), null);
|
||||
assert.equal(dz.pickTrackMatch(results, 'Some Cover Act', 'Levitating'), null);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: loose artist match — one name contained in the other', () => {
|
||||
const results = [{ title: 'Flower Dance', artist: { name: 'DJ Okawari' } }];
|
||||
const m = dz.pickTrackMatch(results, 'Okawari', 'Flower Dance');
|
||||
assert.equal(m, results[0]); // "okawari" ⊂ "djokawari" → accepted
|
||||
});
|
||||
|
||||
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const { test, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const https = require('https');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
|
||||
|
||||
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
|
||||
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
|
||||
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
|
||||
let _antworten = {};
|
||||
let _aufrufe = [];
|
||||
const echtesGet = https.get;
|
||||
|
||||
function stub() {
|
||||
https.get = (url, _opts, cb) => {
|
||||
_aufrufe.push(url);
|
||||
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
process.nextTick(() => {
|
||||
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
|
||||
res.emit('end');
|
||||
});
|
||||
cb(res);
|
||||
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
|
||||
};
|
||||
}
|
||||
stub();
|
||||
process.on('exit', () => { https.get = echtesGet; });
|
||||
|
||||
const inTagen = n => {
|
||||
const d = new Date(Date.now() + n * 864e5);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
|
||||
|
||||
const ereignis = (name, tage, extra = {}) => ({
|
||||
id: 'e' + tage,
|
||||
name,
|
||||
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
|
||||
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
|
||||
url: 'https://tm.example/' + tage,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
|
||||
});
|
||||
|
||||
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
|
||||
_antworten['MORE'] = { _embedded: { events: [
|
||||
ereignis('MORE Tour 2026', 10),
|
||||
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
|
||||
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
|
||||
});
|
||||
|
||||
test('Name im Attraktionsnamen zaehlt auch', async () => {
|
||||
_antworten['K/DA'] = { _embedded: { events: [
|
||||
{ ...ereignis('League Music Night', 15), _embedded: {
|
||||
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
|
||||
attractions: [{ name: 'K/DA' }],
|
||||
} },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
|
||||
});
|
||||
|
||||
test('Termine hinter dem Fenster fallen weg', async () => {
|
||||
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.equal(konzerte[0].titel, 'Coldplay Live');
|
||||
});
|
||||
|
||||
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
|
||||
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
|
||||
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
|
||||
});
|
||||
|
||||
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
|
||||
_antworten['Leer'] = { _embedded: { events: [] } };
|
||||
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.deepEqual(ohneTermine, ['Leer']);
|
||||
});
|
||||
|
||||
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
const nachErstem = _aufrufe.length;
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
|
||||
});
|
||||
|
||||
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
|
||||
_antworten['X'] = { _embedded: { events: [
|
||||
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
|
||||
assert.equal(konzerte[0].abgesagt, true);
|
||||
});
|
||||
|
||||
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.equal(_aufrufe.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { findExistingTrack } = require('../lib/metadata');
|
||||
|
||||
let ROOT;
|
||||
const touch = (artist, album, file) => {
|
||||
const dir = path.join(ROOT, artist, album);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
|
||||
before(() => {
|
||||
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-dedup-'));
|
||||
// KH single: normal "01 - " prefix, title contains multiple " - "
|
||||
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme - Single',
|
||||
'01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
|
||||
// KH album variant: garbage "01-01" artist prefix, no track number
|
||||
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme',
|
||||
'01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
|
||||
// Normal "Artist - Title" file
|
||||
touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3');
|
||||
// Akzent-Schreibweise: liegt MIT Akzent auf der Platte
|
||||
touch('Cécile Corbel', 'SongBook Vol. 2', '03 - Cécile Corbel - Arrietty\u2019s Song.mp3');
|
||||
// Soundtrack: liegt unter dem ALBUM-Kuenstler, nicht unter dem Titel-Kuenstler
|
||||
touch('Arcane', 'Arcane League of Legends (Soundtrack)', '01 - Bea Miller - Playground.mp3');
|
||||
// Zwei verschiedene Kuenstler mit demselben Songtitel — duerfen sich NIE gegenseitig treffen
|
||||
touch('Cro', 'Raop', '03 - Cro - Easy.mp3');
|
||||
touch('Kraftklub', 'Mit K', '05 - Kraftklub - Easy.mp3');
|
||||
// Aehnlicher, aber anderer Kuenstlername
|
||||
touch('Cecil Taylor', 'Unit Structures', '01 - Cecil Taylor - Steps.mp3');
|
||||
});
|
||||
after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
test('detects a re-download when the title itself contains " - " (KH bug)', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
|
||||
});
|
||||
|
||||
test('detects it even with a garbage "01-01 - " artist prefix', () => {
|
||||
// Both KH files should resolve; at least one must be found → dedup skips the re-download.
|
||||
const hit = findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme');
|
||||
assert.ok(hit && hit.includes('Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
|
||||
});
|
||||
|
||||
test('normal "Artist - Title" filename still matches on title alone', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy'));
|
||||
});
|
||||
|
||||
test('no false positive: a different title is not matched', () => {
|
||||
assert.equal(findExistingTrack(ROOT, 'Geek Music', 'Some Unrelated Song'), null);
|
||||
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Kingdom Hearts 2'), null);
|
||||
});
|
||||
|
||||
test('remix stays distinct from the base track (no regression from the version-marker fix)', () => {
|
||||
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null);
|
||||
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found
|
||||
});
|
||||
|
||||
|
||||
// ── Akzente (Cécile Corbel) ────────────────────────────────────────────────────
|
||||
test('findet den Song auch ohne Akzent in der Anfrage', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Cecile Corbel', "Arrietty's Song"),
|
||||
'ASCII-Schreibweise muss die Datei mit Akzent finden');
|
||||
});
|
||||
|
||||
test('findet ihn ebenso mit Akzent', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Cécile Corbel', "Arrietty's Song"));
|
||||
});
|
||||
|
||||
test('kein Fehlalarm: aehnlicher Name ist nicht dieselbe Person', () => {
|
||||
// "Cecil Taylor" darf nicht auf "Cécile Corbel" passen — und umgekehrt.
|
||||
assert.equal(findExistingTrack(ROOT, 'Cécile Corbel', 'Steps'), null);
|
||||
assert.equal(findExistingTrack(ROOT, 'Cecil Taylor', "Arrietty's Song"), null);
|
||||
});
|
||||
|
||||
test('kein Fehlalarm: Akzent-Normalisierung kollabiert keine echten Titel', () => {
|
||||
assert.equal(findExistingTrack(ROOT, 'Cecile Corbel', 'Ein anderer Song'), null);
|
||||
});
|
||||
|
||||
// ── Gleicher Titel, verschiedene Kuenstler ─────────────────────────────────────
|
||||
test('kein Fehlalarm: gleicher Titel bei zwei Kuenstlern bleibt getrennt', () => {
|
||||
const cro = findExistingTrack(ROOT, 'Cro', 'Easy');
|
||||
const kk = findExistingTrack(ROOT, 'Kraftklub', 'Easy');
|
||||
assert.ok(cro && cro.includes('Cro'), 'Cro-Version muss im Cro-Ordner gefunden werden');
|
||||
assert.ok(kk && kk.includes('Kraftklub'), 'Kraftklub-Version im Kraftklub-Ordner');
|
||||
assert.notEqual(cro, kk, 'die beiden duerfen nicht dieselbe Datei sein');
|
||||
// Ein dritter Kuenstler mit demselben Titel findet nichts.
|
||||
assert.equal(findExistingTrack(ROOT, 'Irgendwer', 'Easy'), null);
|
||||
});
|
||||
|
||||
// ── Soundtrack: Album-Kuenstler vs. Titel-Kuenstler ────────────────────────────
|
||||
test('Soundtrack unter dem Album-Kuenstler wird ueber diesen gefunden', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Arcane', 'Playground'));
|
||||
});
|
||||
|
||||
test('unter dem Titel-Kuenstler allein wird er NICHT gefunden (Grund fuer die Zweitpruefung)', () => {
|
||||
// Genau diese Luecke erzeugte die Dublette: geprueft wurde nur "Bea Miller".
|
||||
assert.equal(findExistingTrack(ROOT, 'Bea Miller', 'Playground'), null);
|
||||
});
|
||||
|
||||
test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => {
|
||||
const pruefe = (albumArtist, trackArtist, titel) =>
|
||||
findExistingTrack(ROOT, albumArtist, titel)
|
||||
|| (trackArtist !== albumArtist ? findExistingTrack(ROOT, trackArtist, titel) : null);
|
||||
// Soundtrack-Fall: wird jetzt erkannt.
|
||||
assert.ok(pruefe('Bea Miller', 'Bea Miller', 'Playground') === null,
|
||||
'ohne Album-Kuenstler weiterhin nichts — richtig, die Datei liegt unter Arcane');
|
||||
assert.ok(pruefe('Arcane', 'Bea Miller', 'Playground'), 'mit Album-Kuenstler gefunden');
|
||||
// Fremder Titel im selben Ordner darf trotz zweier Durchlaeufe nicht anschlagen.
|
||||
assert.equal(pruefe('Arcane', 'Bea Miller', 'Irgendein anderer Titel'), null);
|
||||
// Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig.
|
||||
assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null);
|
||||
});
|
||||
|
||||
// ─── Dateisuche: Titelstamm und Schraegstrich im Namen ───────────────────────
|
||||
// Regression zu "K/DA – VILLAIN": Navidromes Tag-Pfad wurde zu "K/DA/ALL OUT/…", auf der Platte
|
||||
// steht "K_DA/ALL OUT/…". Der Pfad fand nichts, und die Aehnlichkeitssuche lieferte
|
||||
// "Bad Omens - Like A Villain", weil deren Stamm auf "villain" ENDET.
|
||||
const { findFileByStem, albumDirFromTags } = require('../lib/metadata');
|
||||
|
||||
let SLASH_ROOT;
|
||||
before(() => {
|
||||
SLASH_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-slash-'));
|
||||
const put = (...segs) => {
|
||||
const file = segs.pop();
|
||||
const dir = path.join(SLASH_ROOT, ...segs);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
put('K_DA', 'ALL OUT', '03 - K_DA - VILLAIN.mp3');
|
||||
put('K_DA', 'POP_STARS', '01 - K_DA - POP_STARS.mp3');
|
||||
put('Bad Omens', 'THE DEATH OF PEACE OF MIND', '06 - Bad Omens - Like A Villain.mp3');
|
||||
});
|
||||
after(() => { try { fs.rmSync(SLASH_ROOT, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
test('Titelstamm trifft nicht auf blosse Endsilbe', () => {
|
||||
// "Like A Villain" darf NICHT als "VILLAIN" durchgehen
|
||||
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'villain');
|
||||
assert.equal(t, null);
|
||||
});
|
||||
|
||||
test('Titelstamm trifft "Kuenstler - Titel" weiterhin', () => {
|
||||
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'like a villain');
|
||||
assert.ok(t && t.endsWith('06 - Bad Omens - Like A Villain.mp3'));
|
||||
});
|
||||
|
||||
test('Ganze Bibliothek: VILLAIN findet K_DA, nicht Bad Omens', () => {
|
||||
const t = findFileByStem(SLASH_ROOT, 'villain');
|
||||
assert.ok(t && t.includes(path.join('K_DA', 'ALL OUT')), `unerwartet: ${t}`);
|
||||
});
|
||||
|
||||
test('Schraegstrich im Kuenstlernamen fuehrt zum richtigen Ordner', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'ALL OUT'), path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'));
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'POP/STARS'), path.join(SLASH_ROOT, 'K_DA', 'POP_STARS'));
|
||||
});
|
||||
|
||||
test('Unbekanntes Album faellt auf den Kuenstlerordner zurueck', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'Gibt Es Nicht'), path.join(SLASH_ROOT, 'K_DA'));
|
||||
});
|
||||
|
||||
test('Mehrere Kuenstler: Ordner haengt am ersten', () => {
|
||||
assert.equal(
|
||||
albumDirFromTags(SLASH_ROOT, 'K/DA, Madison Beer, Kim Petras', 'ALL OUT'),
|
||||
path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Kein Treffer bleibt null', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'Niemand', 'Nichts'), null);
|
||||
});
|
||||
|
||||
// ─── Kuenstler im Dateinamen ────────────────────────────────────────────────
|
||||
// Frueher stand die vollstaendige Gaesteliste im Namen und wurde bei 100 Zeichen mitten im
|
||||
// letzten Namen abgeschnitten. Jetzt nur noch der erste Kuenstler.
|
||||
const { dateinamensKuenstler } = require('../lib/metadata');
|
||||
|
||||
test('Nur der erste Kuenstler landet im Dateinamen', () => {
|
||||
assert.equal(
|
||||
dateinamensKuenstler('K/DA;Madison Beer;i-dle;Lexie Liu;Jaira Burns;Seraphine;League of Legends'),
|
||||
'K/DA',
|
||||
);
|
||||
assert.equal(dateinamensKuenstler('Dove Cameron, Sofia Carson'), 'Dove Cameron');
|
||||
assert.equal(dateinamensKuenstler('Calvin Harris & Dua Lipa'), 'Calvin Harris');
|
||||
});
|
||||
|
||||
test('Bandnamen mit & bleiben ganz, wenn sie keine Liste sind', () => {
|
||||
// "&" trennt nur MIT umgebenden Leerzeichen — "Earth, Wind & Fire" waere ohnehin durch das
|
||||
// Komma getrennt, aber ein reiner Bandname ohne Liste darf nicht zerlegt werden.
|
||||
assert.equal(dateinamensKuenstler('AC/DC'), 'AC/DC');
|
||||
assert.equal(dateinamensKuenstler('Simon&Garfunkel'), 'Simon&Garfunkel');
|
||||
});
|
||||
|
||||
test('Leerer Kuenstler faellt auf den Album-Kuenstler zurueck', () => {
|
||||
assert.equal(dateinamensKuenstler('', 'K/DA;Madison Beer'), 'K/DA');
|
||||
assert.equal(dateinamensKuenstler(undefined, undefined), '');
|
||||
});
|
||||
|
||||
test('Der Dateiname bleibt kurz genug fuers Dateisystem', () => {
|
||||
const lang = 'K/DA;Madison Beer;(G)I-DLE;Lexie Liu;Jaira Burns;Seraphine;League of Legends;Bea Miller;Wolftyla;Duckwrth';
|
||||
const teil = dateinamensKuenstler(lang);
|
||||
assert.equal(teil, 'K/DA');
|
||||
assert.ok(teil.length < 100, 'wird nie von sanitize() abgeschnitten');
|
||||
});
|
||||
|
||||
test('Dublettenpruefung findet auch die alt benannten Dateien', () => {
|
||||
// Nach der Umstellung liegen beide Namensformen nebeneinander. Beide muessen als "schon da"
|
||||
// erkannt werden, sonst laedt der Downloader die alten Dateien ein zweites Mal.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-namen-'));
|
||||
const put = (album, file) => {
|
||||
const dir = path.join(root, 'K_DA', album);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
put('MORE', '01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine - MORE.mp3'); // alt
|
||||
put('ALL OUT', '03 - K_DA - VILLAIN.mp3'); // neu
|
||||
assert.ok(findExistingTrack(root, 'K/DA', 'MORE'), 'alte Namensform nicht erkannt');
|
||||
assert.ok(findExistingTrack(root, 'K/DA', 'VILLAIN'), 'neue Namensform nicht erkannt');
|
||||
assert.equal(findExistingTrack(root, 'K/DA', 'POP/STARS'), null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const mb = require('../lib/musicbrainz');
|
||||
|
||||
const rec = (title, artist, releases = []) => ({
|
||||
id: 'rec-' + title, title, length: 200000,
|
||||
'artist-credit': [{ name: artist, artist: { id: 'a', name: artist } }],
|
||||
releases,
|
||||
});
|
||||
const albumRel = { id: 'rel-1', title: 'Real Album', date: '2021-05-01', 'release-group': { 'primary-type': 'Album' } };
|
||||
const singleRel = { id: 'rel-2', title: 'Some Single', date: '2020-01-01', 'release-group': { 'primary-type': 'Single' } };
|
||||
|
||||
test('mbPickMatch: artist-verified match prefers a recording with a release', () => {
|
||||
const recs = [rec('Memories', 'NoAki'), rec('Memories', 'NoAki', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, 'NoAki', 'Memories'), recs[1]);
|
||||
});
|
||||
|
||||
test('mbPickMatch: completely different artist → null (no wrong album)', () => {
|
||||
const recs = [rec('Memories', 'Rab Noakes', [albumRel]), rec('Memories', 'Maroon 5', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, '☆NoAki☆', 'Memories'), null);
|
||||
});
|
||||
|
||||
test('mbPickMatch: loose artist match (one contained in the other)', () => {
|
||||
const recs = [rec('Flower Dance', 'DJ Okawari', [albumRel])];
|
||||
assert.equal(mb.mbPickMatch(recs, 'Okawari', 'Flower Dance'), recs[0]);
|
||||
});
|
||||
|
||||
test('mapMbRecording: maps album/year/cover/duration and prefers the Album release', () => {
|
||||
const m = mb.mapMbRecording(rec('Memories', 'NoAki', [singleRel, albumRel]));
|
||||
assert.equal(m.album, 'Real Album');
|
||||
assert.equal(m.albumArtist, 'NoAki');
|
||||
assert.equal(m.year, 2021);
|
||||
assert.equal(m.trackName, 'Memories');
|
||||
assert.equal(m.durationMs, 200000);
|
||||
assert.equal(m.artworkUrl, 'https://coverartarchive.org/release/rel-1/front-500');
|
||||
assert.equal(m._source, 'musicbrainz');
|
||||
});
|
||||
|
||||
test('mapMbRecording: no release → no album, no artwork (clean fallback territory)', () => {
|
||||
const m = mb.mapMbRecording(rec('Orphan Song', 'NoAki', []));
|
||||
assert.equal(m.album, undefined);
|
||||
assert.equal(m.artworkUrl, '');
|
||||
assert.equal(m.metaArtistName, 'NoAki');
|
||||
});
|
||||
|
||||
test('mapMbRecording: null in → {} out', () => {
|
||||
assert.deepEqual(mb.mapMbRecording(null), {});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { buildTitleKeywords } = require('../lib/ytdlp.js');
|
||||
|
||||
// The keywords become `title ~= (?i)<kw>` match-filters in yt-dlp, i.e. every keyword must match
|
||||
// the candidate title case-insensitively. This mirrors that check.
|
||||
const accepts = (title, candidate) =>
|
||||
buildTitleKeywords(title).every(kw => new RegExp(kw, 'i').test(candidate));
|
||||
|
||||
test('a number in the title is always required, however short', () => {
|
||||
const kws = buildTitleKeywords('700 Main Street');
|
||||
assert.ok(kws.some(k => k.includes('700')), `700 fehlt in ${JSON.stringify(kws)}`);
|
||||
});
|
||||
|
||||
test('rejects a same-shaped title with a different number', () => {
|
||||
// The real regression: "777 Main Street" (Release - Topic) was downloaded for DAT ADAM's
|
||||
// "700 Main Street", because only the two longest words (Street, Main) were required.
|
||||
assert.equal(accepts('700 Main Street', '777 Main Street'), false);
|
||||
assert.equal(accepts('700 Main Street', '700 Main Street'), true);
|
||||
});
|
||||
|
||||
test('accepts the abbreviated spelling uploaders use', () => {
|
||||
assert.equal(accepts('700 Main Street', '700 Main st (cr-Version)'), true);
|
||||
assert.equal(accepts('700 Main Street', 'DAT ADAM - 700 Main St [FULL SONG]'), true);
|
||||
});
|
||||
|
||||
test('the abbreviation does not match inside another word', () => {
|
||||
assert.equal(accepts('700 Main Street', '700 Main Stuff'), false);
|
||||
});
|
||||
|
||||
test('titles without numbers keep the previous two-longest-words behaviour', () => {
|
||||
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
|
||||
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Bohemian Rhapsody (Official Video)'), true);
|
||||
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Another One Bites The Dust'), false);
|
||||
});
|
||||
|
||||
test('a remaster year is not required — YouTube titles never carry it', () => {
|
||||
// "… - 2020 Remaster" steht auf der Veröffentlichung, der Upload heißt schlicht
|
||||
// "Take Me Home, Country Roads". Vor dieser Ausnahme hätte die 2020 ihn abgelehnt.
|
||||
assert.equal(accepts('Take Me Home, Country Roads - 2020 Remaster',
|
||||
'John Denver - Take Me Home, Country Roads'), true);
|
||||
});
|
||||
|
||||
test('a year that IS the title stays required', () => {
|
||||
assert.equal(accepts('1999', 'Prince - 1999 (Official Music Video)'), true);
|
||||
assert.equal(accepts('1999', 'Prince - 1984 (Official Music Video)'), false);
|
||||
});
|
||||
|
||||
const { buildVariantFilter } = require('../lib/ytdlp.js');
|
||||
|
||||
// Der Variantenfilter ist eine yt-dlp-Ausdruckskette "title !~= (?i)\bx\b & …".
|
||||
// Diese Hilfsfunktion wertet sie so aus, wie yt-dlp es tut.
|
||||
const variantAccepts = (searchTitle, candidate) => {
|
||||
const f = buildVariantFilter(searchTitle);
|
||||
if (!f) return true;
|
||||
return f.split(' & ').every(part => {
|
||||
const m = /^title !~= \(\?i\)(.+)$/.exec(part);
|
||||
return !new RegExp(m[1], 'i').test(candidate);
|
||||
});
|
||||
};
|
||||
|
||||
test('rejects a live recording — is_live only covers running streams', () => {
|
||||
// Der echte Fall: auf dem Kuenstlerkanal, Dauer im Fenster, Titel passend.
|
||||
assert.equal(variantAccepts('UFO', 'Dat Adam - Ufo Video Days 2015 Live'), false);
|
||||
assert.equal(variantAccepts('UFO', 'DAT ADAM - 700 Main St | ROCK AM RING 2017 [LIVE]'), false);
|
||||
assert.equal(variantAccepts('UFO', 'UFO'), true);
|
||||
});
|
||||
|
||||
test('a deliberately live title keeps its live results', () => {
|
||||
assert.equal(variantAccepts('Live Forever', 'Oasis - Live Forever'), true);
|
||||
assert.equal(variantAccepts('Unplugged in New York (Live)', 'Nirvana - All Apologies (Live)'), true);
|
||||
});
|
||||
|
||||
test('"live" inside another word does not disable the rule', () => {
|
||||
// "deliver" enthaelt "live" als Teilstring — die Regel muss trotzdem greifen.
|
||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
|
||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
|
||||
});
|
||||
|
||||
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
|
||||
|
||||
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
|
||||
// Kuenstlerkanal (UC…) auf Platz 9.
|
||||
const YTM_OUT = [
|
||||
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
|
||||
'https://music.youtube.com/watch?v=2KVU6aikZF0',
|
||||
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
|
||||
'https://music.youtube.com/watch?v=phLOTtJaqZk',
|
||||
'https://music.youtube.com/watch?v=p935nH6aj8Y',
|
||||
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
|
||||
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
|
||||
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
|
||||
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
|
||||
'https://music.youtube.com/watch?v=O034JUlf1J4',
|
||||
].join('\n');
|
||||
|
||||
test('a channel hit no longer discards the whole result list', () => {
|
||||
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
|
||||
assert.equal(urls.length, 7);
|
||||
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
|
||||
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
|
||||
assert.ok(!urls.some(u => u.includes('/browse/')));
|
||||
});
|
||||
|
||||
test('nothing but list entries yields no candidates', () => {
|
||||
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
|
||||
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
|
||||
assert.deepEqual(pickYtmVideoUrls('').urls, []);
|
||||
});
|
||||
|
||||
// ─── Japanische Titel bekommen wieder eine Titelpruefung ────────────────────
|
||||
// Regression zum 24.9.2026: "銀髪の少年" (HUNTER×HUNTER, Yoshihisa Hirano) lieferte ein anderes
|
||||
// Stueck desselben Komponisten. Ursache: fuer japanische Titel gab buildTitleKeywords eine LEERE
|
||||
// Liste zurueck, und die Kaskadenschritte 3/4 lassen die Kuenstlerpruefung fallen — uebrig blieb
|
||||
// allein das Dauerfenster, in das auf einem Soundtrack-Album fast jeder Titel passt.
|
||||
|
||||
test('japanischer Titel liefert jetzt ein Muster statt gar nichts', () => {
|
||||
const kws = buildTitleKeywords('銀髪の少年');
|
||||
assert.equal(kws.length, 1);
|
||||
assert.ok(new RegExp(kws[0], 'i').test('銀髪の少年'), 'Originaltitel muss passen');
|
||||
assert.ok(!new RegExp(kws[0], 'i').test('闘う者たち'), 'anderes Stueck darf NICHT passen');
|
||||
});
|
||||
|
||||
test('Romaji-Fassung wird als Alternative akzeptiert', () => {
|
||||
const kws = buildTitleKeywords('銀髪の少年', 'ginpatsu no shounen');
|
||||
const re = new RegExp(kws[0], 'i');
|
||||
assert.ok(re.test('銀髪の少年'), 'Original');
|
||||
assert.ok(re.test('Ginpatsu no Shounen'), 'Romaji mit Leerzeichen');
|
||||
assert.ok(re.test('Ginpatsu-no-Shounen'), 'Romaji mit Bindestrichen');
|
||||
assert.ok(re.test('Hunter x Hunter OST - Ginpatsu no Shounen'), 'als Teil eines laengeren Titels');
|
||||
assert.ok(!re.test('Requiem Aranea'), 'anderes Stueck desselben Albums');
|
||||
});
|
||||
|
||||
test('Romaji, die gar keine sind, werden nicht als Alternative genommen', () => {
|
||||
// romajiForStorage gibt den Text unveraendert zurueck, wenn der Tokenizer fehlt
|
||||
const kws = buildTitleKeywords('銀髪の少年', '銀髪の少年');
|
||||
assert.equal(kws.length, 1);
|
||||
assert.ok(!kws[0].includes('|'), 'keine doppelte Alternative');
|
||||
});
|
||||
|
||||
test('Sonderzeichen im japanischen Titel bleiben woertlich', () => {
|
||||
const kws = buildTitleKeywords('君の名は。(Movie ver.)');
|
||||
const re = new RegExp(kws[0], 'i');
|
||||
assert.ok(re.test('君の名は。(Movie ver.)'));
|
||||
assert.ok(!re.test('君の名はX Movie verY'), 'Punkt darf nicht als Platzhalter wirken');
|
||||
});
|
||||
|
||||
test('lateinische Titel bleiben unveraendert', () => {
|
||||
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
|
||||
assert.deepEqual(buildTitleKeywords('Dearly Beloved'), ['Beloved', 'Dearly']);
|
||||
});
|
||||
|
||||
test('Umschrift toleriert die ueblichen Schreibvarianten', () => {
|
||||
const re = new RegExp(buildTitleKeywords('銀髪の少年', 'Ginpatsu No Shounen')[0], 'i');
|
||||
assert.ok(re.test('Ginpatsu no Shounen'), 'wie der Tokenizer schreibt');
|
||||
assert.ok(re.test('Ginpatsu no Shonen'), 'ohne langes o — die haeufigste Variante');
|
||||
assert.ok(re.test('Ginpatsu-no-Shonen'), 'mit Bindestrichen');
|
||||
assert.ok(re.test('GINPATSU NO SHŌNEN'), 'mit Makron');
|
||||
assert.ok(!re.test('Ginpatsu no Otona'), 'anderer Titel faellt weiterhin durch');
|
||||
});
|
||||
|
||||
test('Katakana-Laengungsstrich faellt aus der Umschrift', () => {
|
||||
// romajiForStorage laesst "ー" stehen ("TeーZe"); in einem lateinischen Titel steht es nie.
|
||||
const re = new RegExp(buildTitleKeywords('残酷な天使のテーゼ', 'Zankoku Na Tenshi No TeーZe')[0], 'i');
|
||||
assert.ok(re.test('Zankoku na Tenshi no Teze'), 'ohne Strich geschrieben');
|
||||
assert.ok(re.test('残酷な天使のテーゼ'), 'Originaltitel weiterhin');
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
# NovaSonic — DEV/Selbstbauen: baut lokal aus den Dockerfiles statt Images zu ziehen. Die schweren
|
||||
# Deps (ffmpeg, Deno, yt-dlp, node libs) stecken in den Images, also sind RESTARTS sofort da und ein
|
||||
# Rebuild nach Code-Änderungen ist schnell (Docker cacht die Dep-Layer — nur der COPY-Layer ändert sich).
|
||||
#
|
||||
# git pull
|
||||
# docker compose -f docker-compose.dev.yml up -d --build # geänderte Images neu bauen + starten
|
||||
# docker compose -f docker-compose.dev.yml up -d # nur starten, mit den gebauten Images
|
||||
#
|
||||
# Keine Registry nötig. Der Normalfall (fertige Images ziehen) ist docker-compose.yml.
|
||||
services:
|
||||
|
||||
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
|
||||
novasonic-frontend:
|
||||
build: .
|
||||
container_name: novasonic-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # route this port through your reverse proxy
|
||||
environment:
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- SCROBBLE_URL=${SCROBBLE_URL}
|
||||
|
||||
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||
novasonic-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: novasonic-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
|
||||
- novasonic-data:/data # writable data dir for overrides, state, etc.
|
||||
ports:
|
||||
- "3700:3700"
|
||||
environment:
|
||||
- PORT=3700
|
||||
- MUSIC_PATH=${MUSIC_PATH}
|
||||
- DATA_PATH=/data
|
||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
||||
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
|
||||
- GITEA_URL=${GITEA_URL:-}
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
|
||||
|
||||
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
|
||||
novasonic-scrobble:
|
||||
build:
|
||||
context: ./scrobble
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
|
||||
ports:
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
|
||||
|
||||
volumes:
|
||||
novasonic-data:
|
||||
novasonic-scrobble-data:
|
||||
+36
-17
@@ -1,45 +1,64 @@
|
||||
# NovaSonic — Standard/Produktiv: zieht die fertigen Images aus der Registry, baut nichts.
|
||||
# Auf dem Server nur .env danebenlegen (siehe .env.example) und:
|
||||
#
|
||||
# docker compose pull
|
||||
# docker compose up -d
|
||||
#
|
||||
# Die Images kommen von der Dev-Maschine: `npm run push-images` (baut die drei Dockerfiles und
|
||||
# pusht nach ${REGISTRY}). Selbst bauen statt ziehen: docker-compose.dev.yml.
|
||||
services:
|
||||
|
||||
# ── Frontend (nginx) ──────────────────────────────────────────────────────────
|
||||
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
|
||||
novasonic-frontend:
|
||||
build: .
|
||||
image: ${REGISTRY}/novasonic-frontend:${TAG:-latest}
|
||||
container_name: novasonic-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # route this port through your reverse proxy
|
||||
environment:
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
||||
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- SCROBBLE_URL=${SCROBBLE_URL}
|
||||
|
||||
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||
novasonic-backend:
|
||||
image: node:20-slim
|
||||
image: ${REGISTRY}/novasonic-backend:${TAG:-latest}
|
||||
container_name: novasonic-backend
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./backend:/app:ro # mount entire backend dir (server + lib/)
|
||||
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
|
||||
- novasonic-data:/data # writable data dir for overrides, state, etc.
|
||||
ports:
|
||||
- "3700:3700" # route this port through your reverse proxy
|
||||
- "3700:3700"
|
||||
environment:
|
||||
- PORT=3700
|
||||
- MUSIC_PATH=${MUSIC_PATH} # same path inside the container
|
||||
- MUSIC_PATH=${MUSIC_PATH}
|
||||
- DATA_PATH=/data
|
||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
||||
command: >
|
||||
sh -c "
|
||||
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git && rm -rf /var/lib/apt/lists/* &&
|
||||
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp &&
|
||||
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
|
||||
mkdir -p /deps && cd /deps && npm install express kuromoji &&
|
||||
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
|
||||
"
|
||||
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
|
||||
- GITEA_URL=${GITEA_URL:-}
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
|
||||
|
||||
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ───────
|
||||
novasonic-scrobble:
|
||||
image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest}
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
|
||||
ports:
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
|
||||
|
||||
volumes:
|
||||
novasonic-data: # persistent storage for romaji overrides and app state
|
||||
novasonic-data:
|
||||
novasonic-scrobble-data:
|
||||
@@ -3,5 +3,6 @@
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
|
||||
window.NS_BACKEND_URL = "${BACKEND_URL:-}";
|
||||
window.NS_SCROBBLE_URL = "${SCROBBLE_URL:-}";
|
||||
EOF
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,234 @@
|
||||
# NovaSonic Scrobble & Listening-Archive — Plan
|
||||
|
||||
> Status: **Planung** (kein Code). Eigenes, zeitgestempeltes Play-Log als Grundlage für
|
||||
> Tages-/Monats-/Jahres-Statistiken und ein „Wrapped", **multi-user**, Web + Android.
|
||||
> Ersetzt Koito/Spindle-Ideen (beide effektiv single-user).
|
||||
|
||||
## Warum überhaupt eigenes Log
|
||||
|
||||
- Navidrome speichert nur `playCount` + `playDate` (letztes Datum) — **keine Historie mit
|
||||
Zeitstempeln**. Damit sind „Plays pro Tag/Monat/Jahr" und ein Jahres-Wrapped **nicht** möglich.
|
||||
- **Koito** und **Spindle** sind praktisch **single-user** (ein Admin/DEFAULT_USER, eine Historie).
|
||||
Für mehrere Navidrome-Nutzer im Haushalt passt keins.
|
||||
- NovaSonic **ist der Player** → kennt bei jedem Abspielen: **wer + Song + wann + wie lange**.
|
||||
Das ist der natürliche Erfassungspunkt, pro Nutzer.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entscheidung: eigener Container statt im Download-Backend
|
||||
|
||||
**Empfehlung: separater, schlanker Service `novasonic-scrobble`.**
|
||||
|
||||
Die zwei Workloads haben **gegensätzliche Profile**:
|
||||
|
||||
| | Download-Backend | Scrobble-Service |
|
||||
|---|---|---|
|
||||
| Last | schwer, bursty (yt-dlp/ffmpeg/spotdl-Subprozesse) | leicht, konstant (wenige Writes/Song) |
|
||||
| Uptime-Bedarf | egal, darf abstürzen/neustarten | **hoch** — darf keine Plays verlieren |
|
||||
| Neustart-Kadenz | **regelmäßig** (zieht yt-dlp bei jedem Start neu) | selten |
|
||||
| Kritikalität der Daten | reproduzierbar (erneut downloaden) | **Archiv, nicht reproduzierbar** |
|
||||
| Deps | python3, ffmpeg, git, kuromoji … | nur `node:sqlite` (0 native Deps) |
|
||||
|
||||
Ein `yt-dlp`/`ffmpeg`-OOM oder der Routine-Neustart „yt-dlp neu ziehen" darf **nie** in-flight
|
||||
Scrobbles verlieren oder die DB gefährden. Isolation = Datensicherheit. Der Scrobble-Service
|
||||
startet in Sekunden (winziges Image), läuft stabil durch, besitzt sein eigenes DB-Volume.
|
||||
|
||||
**Kosten:** eine zusätzliche Compose-Service + ein Upstream in der nginx-Config. Gering, weil der
|
||||
Service trivial ist (Express + SQLite + ~4 Routen).
|
||||
|
||||
### Alternative (verworfen): alles im Download-Backend
|
||||
Weniger Dateien, aber koppelt das **Archiv** an den instabilsten/am häufigsten neugestarteten
|
||||
Prozess. Für einen reinen MVP machbar, aber die Datenintegrität ist es nicht wert.
|
||||
|
||||
### Node-/SQLite-Wahl für den neuen Service
|
||||
Neues Image auf **`node:22-slim`** → eingebautes **`node:sqlite`** (stabil ab Node 22), **keine
|
||||
native Build-Kette**, kein `better-sqlite3`-Kompilat. Das bestehende Download-Backend bleibt auf
|
||||
`node:20-slim` unangetastet.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (SQLite, im eigenen Volume)
|
||||
|
||||
DB-Datei: `/data/novasonic-plays.db` (eigenes `novasonic-scrobble-data`-Volume).
|
||||
**Denormalisiert** — Titel/Artist/Album werden mitgespeichert, damit das Archiv überlebt, wenn ein
|
||||
Song später aus Navidrome verschwindet (Kern eines Archivs).
|
||||
|
||||
```sql
|
||||
CREATE TABLE plays (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user TEXT NOT NULL, -- Navidrome-Username → Mandanten-Trennung
|
||||
song_id TEXT NOT NULL, -- Subsonic id (kann später ungültig werden)
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
artist_id TEXT,
|
||||
album TEXT,
|
||||
album_id TEXT,
|
||||
duration INTEGER, -- Track-Länge in Sekunden
|
||||
played_at INTEGER NOT NULL, -- Unix-Epoch, UTC
|
||||
source TEXT, -- 'web' | 'android'
|
||||
uuid TEXT UNIQUE -- Idempotenz (Offline-Retry doppelt-sicher)
|
||||
);
|
||||
CREATE INDEX ix_plays_user_time ON plays(user, played_at);
|
||||
CREATE INDEX ix_plays_user_artist ON plays(user, artist_id);
|
||||
CREATE INDEX ix_plays_user_song ON plays(user, song_id);
|
||||
```
|
||||
|
||||
- **`uuid UNIQUE`**: Web/Android queuen Plays offline und flushen später → ohne Idempotenz zählt ein
|
||||
Retry doppelt. Client erzeugt die UUID; Ingest ignoriert Duplikate (`INSERT OR IGNORE`).
|
||||
- **Zeitzone**: immer **UTC** speichern; beim Aggregieren die lokale TZ des Users anwenden
|
||||
(Client schickt Offset/IANA-TZ), sonst rutscht ein Play um Mitternacht in den falschen Tag.
|
||||
- **Rollups**: bewusst **keine** materialisierten Tabellen zu Beginn — On-the-fly `GROUP BY` reicht
|
||||
für persönliche Datenmengen locker (100k Zeilen sind für SQLite nichts). Nachrüstbar.
|
||||
|
||||
---
|
||||
|
||||
## Was zählt als „Play"
|
||||
|
||||
**Umgesetzt (besser als der ursprüngliche 50%/4-min-Plan):** Wir docken am **bestehenden
|
||||
`scrobbleMode`-Moment** an — genau dann, wenn NovaSonic ohnehin den echten Navidrome-Scrobble
|
||||
absendet (nicht das `submission=false`-„läuft gerade"), feuert **zusätzlich** `logPlay()`.
|
||||
|
||||
Vorteile: (1) **konsistent mit Last.fm** — beide zählen denselben Play zur selben Zeit;
|
||||
(2) respektiert die **vom User gewählte** Scrobble-Schwelle (15s / 30s / 1/3 / 2/3 / end) statt
|
||||
einer fest verdrahteten Regel; (3) eine einzige Wahrheit, weniger Code. Der Play-Log ersetzt den
|
||||
Navidrome/Last.fm-Scrobble **nie** — er läuft parallel.
|
||||
|
||||
Web: [src/player/player.js](../src/player/player.js) `_scheduleScrobble`/`_scrobbleNow`.
|
||||
Android: [PlayerController.kt](../android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt) `scheduleScrobble`.
|
||||
|
||||
---
|
||||
|
||||
## API (Service `novasonic-scrobble`)
|
||||
|
||||
| Endpoint | Zweck | Status |
|
||||
|---|---|---|
|
||||
| `POST /scrobble/plays` | Batch-Ingest `{plays:[…]}`, idempotent per `uuid` | ✅ Phase 1 |
|
||||
| `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 |
|
||||
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 |
|
||||
| `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 |
|
||||
| `GET /scrobble/stats?from=&to=&tz=&bucket=day\|month\|year&limit=` | Aggregate: totals, Top-Artists/Alben/Tracks, Zeit-Serie (day/month/year), Hör-Uhr (byHour) | ✅ Phase 3 (Endpoint) |
|
||||
| `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 |
|
||||
|
||||
### Routing: eigene Origin, KEIN nginx-Upstream
|
||||
Der Frontend-nginx serviert nur Static und **proxyt nichts** — der Client spricht das Download-Backend
|
||||
direkt über `S.cfg.backendUrl` (`window.NS_BACKEND_URL`) an. Der Scrobble-Service folgt exakt diesem
|
||||
Muster: eigene Origin `S.cfg.scrobbleUrl` (`window.NS_SCROBBLE_URL`, aus `SCROBBLE_URL` in der
|
||||
`.env` → `docker-entrypoint.sh`). CORS `*` wie im Backend. Der User routet Port **3710** über seinen
|
||||
Reverse-Proxy (wie 8080/3700).
|
||||
|
||||
### Auth / Multi-User (Trust-Modell)
|
||||
Jeder Call trägt die Subsonic-Token-Creds, die NovaSonic ohnehin nutzt (`u`, `t=md5(pass+salt)`,
|
||||
`s=salt`). Der Service verifiziert den User einmal per günstigem Subsonic **`ping.view`** gegen
|
||||
Navidrome (5 min gecacht) → der `user`-Wert im Play ist damit echt, nicht spoofbar. Kein neues
|
||||
Login, kein neues Passwort.
|
||||
|
||||
### Aggregation Tag / Monat / Jahr (das Archiv-Herzstück)
|
||||
```sql
|
||||
-- Plays pro Tag im Zeitraum (User + lokale TZ via Offset-Sekunden)
|
||||
SELECT strftime('%Y-%m-%d', played_at + :tzOffset, 'unixepoch') AS bucket,
|
||||
COUNT(*) AS plays, SUM(duration) AS seconds
|
||||
FROM plays
|
||||
WHERE user = :user AND played_at BETWEEN :from AND :to
|
||||
GROUP BY bucket ORDER BY bucket;
|
||||
```
|
||||
Gleiche Query mit `%Y-%m` → Monat, `%Y` → Jahr. Top-Listen analog via `GROUP BY artist_id` etc.
|
||||
|
||||
### Wrapped-Payload (Beispiel-Felder)
|
||||
`year`, `totalPlays`, `totalMinutes`, `topArtists[]`, `topAlbums[]`, `topTracks[]`,
|
||||
`mostActiveDay`, `listeningClock` (Plays je Stunde 0–23), `monthlyBreakdown[12]`,
|
||||
`newArtistsDiscovered` (erste je Artist im Jahr), `streakLongest`.
|
||||
|
||||
---
|
||||
|
||||
## Erfassung im Client
|
||||
|
||||
- **Web** ([src/api.js](../src/api.js) / Player): im `timeupdate`/`ended`-Handler die 50 %/4-min-
|
||||
Schwelle prüfen → lokale Queue (localStorage) → Flush an `/scrobble/plays`. Läuft **parallel** zum
|
||||
bestehenden Navidrome-Scrobble (Play-Count bleibt unangetastet).
|
||||
- **Android** (Media3-Listener): gleiche Schwelle über Position/State → Room-Queue → Flush.
|
||||
Nutzt dieselbe Offline-Queue-Denke wie das bestehende Offline-System.
|
||||
|
||||
Beide: Queue + Retry, damit ein Play auch offline erfasst und später idempotent nachgereicht wird.
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
- **Web:** neue View „Statistiken" — Kalender-Heatmap (Tage), Monats-Balken, Top-Listen; **Wrapped**
|
||||
als Story-Seite zum Jahresende. Vanilla SVG/Canvas, **keine Chart-Lib** (Bundle schlank halten).
|
||||
- **Android:** Stats-Screen (Compose), spiegelbildlich zur Web-View.
|
||||
|
||||
---
|
||||
|
||||
## Optional später: ListenBrainz-Relay
|
||||
|
||||
Jeden Play zusätzlich an einen **ListenBrainz-kompatiblen** Endpoint schieben (falls konfiguriert)
|
||||
→ NovaSonic wird nebenbei ein Standard-Scrobbler, Koito/LB als externes Ziel andockbar. Nicht
|
||||
Teil des Kerns.
|
||||
|
||||
---
|
||||
|
||||
## docker-compose (Skizze)
|
||||
|
||||
```yaml
|
||||
novasonic-scrobble:
|
||||
image: node:22-slim
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./scrobble:/app:ro
|
||||
- novasonic-scrobble-data:/data
|
||||
ports:
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # für ping.view-Verifikation
|
||||
command: >
|
||||
sh -c "mkdir -p /deps && cd /deps && npm install express &&
|
||||
cd /app && NODE_PATH=/deps/node_modules node scrobble-server.js"
|
||||
|
||||
volumes:
|
||||
novasonic-scrobble-data:
|
||||
```
|
||||
|
||||
`.env`: `SCROBBLE_URL=https://scrobble.deine-domain` ergänzen; der Frontend-Container injiziert das als
|
||||
`window.NS_SCROBBLE_URL` (via `docker-entrypoint.sh`). Kein nginx-Upstream nötig — eigene Origin, CORS `*`.
|
||||
|
||||
---
|
||||
|
||||
## Phasen
|
||||
|
||||
1. ✅ **Service-Fundament** *(erledigt)*: `scrobble/scrobble-server.js` (node:22 + `node:sqlite`),
|
||||
`POST /scrobble/plays` (idempotent) + `GET /scrobble/history` + `DELETE /scrobble/plays` +
|
||||
`GET /scrobble/health`, `ping.view`-Auth (5 min gecacht), eigenes Volume, Compose-Service,
|
||||
`SCROBBLE_URL`-Plumbing (entrypoint + `S.cfg.scrobbleUrl`), Backend auf node:22 hochgezogen.
|
||||
Lokal end-to-end getestet (Ingest/Idempotenz/History/Purge). *Ab hier wird bereits archiviert.*
|
||||
2. ✅ **Erfassung Web + Android** *(erledigt)*: Play-Log am `scrobbleMode`-Moment (s.o.),
|
||||
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
|
||||
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
|
||||
Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.*
|
||||
3. ✅ **Erledigt**: `/scrobble/stats` (Endpoint: totals, Top-Listen, day/month/year-Serie, Hör-Uhr;
|
||||
on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) **+ Web-Statistik-View**
|
||||
([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"),
|
||||
Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen,
|
||||
Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.**
|
||||
4. 🔨 **In Arbeit**: **Android-Stats-Screen** ✅ (konsumiert `/scrobble/stats`; Kacheln, Verlaufs-
|
||||
Balken, Top-Listen, Hör-Uhr; Zeitraum-Tabs; aus den Android-Einstellungen). **Cover + Artist-
|
||||
Bilder** ✅ in Web **und** Android (client-seitig aus Navidrome via `coverUrl`/`coverArtUrl` über
|
||||
`artistId`/`albumId`/`songId`; Endpoint liefert jetzt auch `albumId` bei Top-Tracks).
|
||||
**Noch offen: Wrapped** (`/scrobble/wrapped` + Web-Story-Seite). Android + Web build grün;
|
||||
**braucht Runtime-Test am Gerät.**
|
||||
5. *(optional)* ListenBrainz-Relay.
|
||||
|
||||
---
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- **Container-Netzwerk/Proxy:** eigener Port (3710) hinter nginx-Upstream `/scrobble/` — bestätigen.
|
||||
- **TZ-Übergabe:** fester UTC-Offset pro Request vs. IANA-Name (Sommerzeit-genau). Start: Offset,
|
||||
später IANA falls DST-Grenzfälle stören.
|
||||
- **Schwelle:** 50 %/4 min (Last.fm) übernehmen oder anpassen?
|
||||
- **Aufbewahrung:** unbegrenzt (Archiv). Backup = das SQLite-File im Volume sichern.
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Teilen per Link
|
||||
|
||||
Ein geteilter Link sieht so aus:
|
||||
|
||||
```
|
||||
https://<deine-web-adresse>/?song=<navidrome-song-id>
|
||||
https://<deine-web-adresse>/?album=<navidrome-album-id>
|
||||
```
|
||||
|
||||
Er trägt **nur die Id**, keine Zugangsdaten. Der Empfänger sieht den Inhalt mit seinem eigenen
|
||||
Navidrome-Konto auf derselben Instanz — wer dort keinen Zugang hat, sieht nichts.
|
||||
|
||||
## Web
|
||||
|
||||
Funktioniert ohne weitere Einrichtung. Der Link wird beim Start ausgelesen (`?song=` / `?album=`),
|
||||
das Album geöffnet und der Song abgespielt; danach verschwindet der Parameter aus der Adresszeile,
|
||||
damit ein Neuladen nicht erneut startet. Als installierte PWA landet der Link dank
|
||||
`launch_handler: navigate-existing` im bereits offenen Fenster statt in einem neuen.
|
||||
|
||||
## Android
|
||||
|
||||
Zwei Dinge sind nötig, damit ein Link **direkt in der App** landet statt im Browser:
|
||||
|
||||
**1. Web-Adresse in der App hinterlegen** — Einstellungen → „NovaSonic Web-Adresse". Ohne sie
|
||||
erscheint der Teilen-Eintrag im Kontextmenü gar nicht erst, weil der Link sonst ins Leere zeigt.
|
||||
|
||||
**2. Den Host beim Bauen mitgeben** und die Signatur hinterlegen:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease -PnovasonicHost=music.example.com
|
||||
```
|
||||
|
||||
Dann den SHA-256 des **Release**-Signaturschlüssels ermitteln …
|
||||
|
||||
```bash
|
||||
keytool -list -v -keystore <dein-keystore>.jks -alias <alias> | grep SHA256
|
||||
```
|
||||
|
||||
… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen.
|
||||
Die Datei muss unter `https://<deine-web-adresse>/.well-known/assetlinks.json` erreichbar sein —
|
||||
sie liegt im `public/`-Verzeichnis und wird vom Frontend-Image mit ausgeliefert.
|
||||
|
||||
## APK veröffentlichen
|
||||
|
||||
`npm run publish-apk` gibt den Host jetzt an Gradle weiter. Dafür in `scripts/.env.release`
|
||||
ergänzen (die Datei wird nie eingecheckt):
|
||||
|
||||
```
|
||||
NOVASONIC_HOST=music.deine-domain.de
|
||||
# optional während eines Domainwechsels:
|
||||
# NOVASONIC_HOST2=alt.deine-domain.de
|
||||
```
|
||||
|
||||
Fehlt der Eintrag, warnt das Skript und baut mit dem Platzhalter weiter — das APK ist dann
|
||||
installierbar, aber eingehende Links öffnen im Browser statt in der App.
|
||||
|
||||
## Domain wechseln
|
||||
|
||||
**Ausgehende Links** folgen der Einstellung „NovaSonic Web-Adresse" — Adresse ändern, fertig, kein
|
||||
neues APK nötig.
|
||||
|
||||
**Eingehende Links** stehen fest im Manifest und brauchen ein neues APK. Damit während des Umzugs
|
||||
beide Adressen funktionieren, lassen sich zwei Hosts angeben:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease -PnovasonicHost=neu.example.com -PnovasonicHost2=alt.example.com
|
||||
```
|
||||
|
||||
Beide müssen eine gültige `assetlinks.json` ausliefern. Das ist keine Feinheit: **auf Android 11 und
|
||||
älter verifiziert das System nur, wenn ALLE angegebenen Hosts eine passende Datei liefern** — ein
|
||||
toter Host blockiert also auch den funktionierenden. Sobald die alte Domain abgeschaltet wird, das
|
||||
APK deshalb ohne `novasonicHost2` neu bauen.
|
||||
|
||||
### Ablauf eines Umzugs
|
||||
|
||||
1. **Neue Domain aufsetzen**, Web-App und `/.well-known/assetlinks.json` dort erreichbar machen.
|
||||
Die alte Domain bleibt vorerst unverändert online.
|
||||
2. **APK mit beiden Hosts bauen** (`-PnovasonicHost=neu -PnovasonicHost2=alt`) und installieren.
|
||||
Ab jetzt öffnen Links beider Adressen in der App.
|
||||
3. **In der App die „NovaSonic Web-Adresse" auf die neue Domain umstellen.** Neu geteilte Links
|
||||
zeigen ab sofort dorthin, alte funktionieren weiter.
|
||||
4. **Warten**, bis auf allen Geräten das neue APK läuft.
|
||||
5. **Alte Domain abschalten** — und danach das APK ohne `novasonicHost2` neu bauen.
|
||||
|
||||
Schritt 5 nicht vergessen: Android verifiziert die Domains bei jeder Installation neu, und auf
|
||||
Android 11 und älter genügt ein einziger nicht erreichbarer Host, damit die Verifikation komplett
|
||||
fehlschlägt. Bereits verifizierte Installationen laufen zwar weiter, aber die nächste Neuinstallation
|
||||
hätte gar keine funktionierenden App Links mehr.
|
||||
|
||||
Zwei weitere Fallen:
|
||||
|
||||
- **Signaturschlüssel muss derselbe bleiben.** Ein anders signiertes APK lässt sich nur nach
|
||||
Deinstallation installieren — und die nimmt die Offline-Downloads mit.
|
||||
- **Alte Links nach der Abschaltung** öffnen nur dann noch etwas, wenn die alte Domain auf die neue
|
||||
weiterleitet. Dann landen sie allerdings im Browser statt in der App, weil Android eine
|
||||
Weiterleitung nicht erneut an die App übergibt.
|
||||
|
||||
Wildcards (`*.example.com`) sind für App Links nicht dokumentiert und werden hier bewusst nicht
|
||||
verwendet — die Verifikation läuft pro Host.
|
||||
|
||||
Bereits verschickte Links auf die alte Domain öffnen nach deren Abschaltung gar nichts mehr; das
|
||||
lässt sich nur mit einer Weiterleitung von alt nach neu abfangen.
|
||||
|
||||
Prüfen lässt sich das Ergebnis mit:
|
||||
|
||||
```bash
|
||||
adb shell pm get-app-links com.novasonic.app
|
||||
```
|
||||
|
||||
Steht dort `verified`, öffnet Android die Links ohne Nachfrage.
|
||||
|
||||
**Ohne diesen Schritt** ist der Link trotzdem nicht verloren: er öffnet im Browser die Web-App.
|
||||
Wer die App bevorzugt, kann sie in den Systemeinstellungen unter *Standardmäßig öffnen* → *Links*
|
||||
manuell für die Domain zulassen.
|
||||
+165
-99
@@ -64,12 +64,6 @@
|
||||
<div class="nav-item" onclick="navigate('albums')" data-page="albums">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>Alben
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('playlists')" data-page="playlists">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Playlists
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('starred')" data-page="starred">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Favoriten
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Entdecken</div>
|
||||
@@ -85,9 +79,6 @@
|
||||
<div class="nav-item" onclick="navigate('random')" data-page="random">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>Zufällig
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>Song Importer
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section" id="nav-abs-section" style="display:none">
|
||||
<div class="nav-section-title">Hörbücher</div>
|
||||
@@ -95,15 +86,33 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>Bibliothek
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deine Musik: Favoriten, "Neu von deinen Kuenstlern" und die Playlists als LISTE
|
||||
statt als Knopf, der erst eine Uebersichtsseite oeffnet. Der Listenteil rollt
|
||||
eigenstaendig, damit die Navigation darueber immer sichtbar bleibt. -->
|
||||
<div class="nav-section lib-section">
|
||||
<div class="lib-head">
|
||||
<span class="nav-section-title">Deine Musik</span>
|
||||
<button class="lib-btn" onclick="navigate('playlists')" title="Alle Playlists als Kacheln">Alle</button>
|
||||
<button class="lib-btn lib-btn-add" onclick="createPlaylistPrompt()" title="Neue Playlist" aria-label="Neue Playlist">+</button>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('starred')" data-page="starred">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Favoriten
|
||||
</div>
|
||||
<div class="nav-item" onclick="navigate('artist-news')" data-page="artist-news">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3z"/></svg>Neu von deinen Künstlern
|
||||
</div>
|
||||
<div class="lib-list" id="sidebar-playlists"></div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="mobile-overlay" id="mobile-overlay" onclick="closeSidebar()"></div>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="main" id="main-content">
|
||||
|
||||
<!-- Mobile header -->
|
||||
<!-- Kopfzeile liegt AUSSERHALB des Rollbereichs: sonst laeuft der Rollbalken bis ganz
|
||||
nach oben neben die Suchleiste, obwohl dort nichts rollt. -->
|
||||
<div class="main-wrap">
|
||||
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
|
||||
<div class="header-search-wrap">
|
||||
@@ -113,7 +122,7 @@
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
|
||||
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<!-- Honigtopf mit Brief statt Glocke --><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(-4.56 -4.54) scale(1.38)"><rect x="3.6" y="4.6" width="16.8" height="3" rx="1.1"/><path d="M5.5 7.6c-1.1 3.9-.7 8.2.7 10.4a2 2 0 0 0 1.7.9h8.2a2 2 0 0 0 1.7-.9c1.4-2.2 1.8-6.5.7-10.4"/><rect x="8.3" y="11.7" width="7.4" height="4.6" rx="0.7" stroke-width="1.25"/><path d="M8.6 12.1 12 14.4l3.4-2.3" stroke-width="1.25"/></g></svg>
|
||||
<span class="notif-badge" style="display:none">0</span>
|
||||
</button>
|
||||
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
|
||||
@@ -121,10 +130,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results panel: spans the full main-content width (anchored to the header, which already
|
||||
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
|
||||
<div id="header-search-overlay" style="display:none">
|
||||
<div id="header-search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<main class="main" id="main-content">
|
||||
|
||||
<!-- HOME -->
|
||||
<div class="page active" id="page-home">
|
||||
@@ -145,6 +157,14 @@
|
||||
<!-- ARTISTS -->
|
||||
<div class="page" id="page-artists">
|
||||
<div class="page-header"><div class="page-title">Künstler</div></div>
|
||||
<div id="artists-filter" class="sort-chips">
|
||||
<button class="sort-chip active" data-filter="all" onclick="loadArtists('all')">Alle</button>
|
||||
<button class="sort-chip" data-filter="followed" onclick="loadArtists('followed')">❤ Gefolgt <span id="artists-followed-count"></span></button>
|
||||
</div>
|
||||
<div class="list-search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="artists-search" class="list-search" type="text" placeholder="Künstler suchen…" oninput="filterArtists(this.value)">
|
||||
</div>
|
||||
<div id="artists-grid" class="grid grid-4"></div>
|
||||
</div>
|
||||
|
||||
@@ -166,22 +186,22 @@
|
||||
<div id="artist-songs-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- ITUNES ALBUM DETAIL -->
|
||||
<div class="page" id="page-itunes-album-detail">
|
||||
<button class="back-btn" id="itunes-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
||||
<!-- DEEZER ALBUM DETAIL -->
|
||||
<div class="page" id="page-deezer-album-detail">
|
||||
<button class="back-btn" id="deezer-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
||||
<div class="album-header">
|
||||
<div class="album-cover" id="itunes-album-cover-wrap"><div class="no-art">💿</div></div>
|
||||
<div class="album-cover" id="deezer-album-cover-wrap"><div class="no-art">💿</div></div>
|
||||
<div class="album-meta">
|
||||
<div class="album-type">Deezer</div>
|
||||
<div class="album-name" id="itunes-album-detail-name">—</div>
|
||||
<div class="album-artist-name" id="itunes-album-detail-artist"></div>
|
||||
<div class="album-stats" id="itunes-album-detail-stats"></div>
|
||||
<div class="album-name" id="deezer-album-detail-name">—</div>
|
||||
<div class="album-artist-name" id="deezer-album-detail-artist"></div>
|
||||
<div class="album-stats" id="deezer-album-detail-stats"></div>
|
||||
<div class="album-actions">
|
||||
<button class="btn-outline" id="itunes-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Album laden</button>
|
||||
<button class="btn-outline" id="deezer-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Album laden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="itunes-album-songs"></div>
|
||||
<div id="deezer-album-songs"></div>
|
||||
</div>
|
||||
|
||||
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
|
||||
@@ -229,6 +249,7 @@
|
||||
<div class="album-actions">
|
||||
<button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
|
||||
<button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
|
||||
<button class="btn-outline" id="btn-star-album" title="Album markieren"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span>Merken</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +275,14 @@
|
||||
|
||||
<!-- STARRED -->
|
||||
<div class="page" id="page-starred">
|
||||
<div class="page-header"><div class="page-title">Favoriten ⭐</div></div>
|
||||
<div class="page-header">
|
||||
<div class="page-title">Favoriten ⭐</div>
|
||||
<!-- Einstieg in die Zeitleiste steht dort, wo auch die gefolgten Kuenstler stehen —
|
||||
gleiche Stelle wie in Android. Erscheint nur, wenn man ueberhaupt jemandem folgt. -->
|
||||
<button id="starred-news-btn" class="btn-outline" style="display:none;font-size:12px;padding:8px 14px" onclick="navigate('artist-news')">
|
||||
✨ Neu
|
||||
</button>
|
||||
</div>
|
||||
<div id="starred-sort-chips" class="sort-chips">
|
||||
<button class="sort-chip active" data-sort="newest" onclick="loadStarred('newest')">Neueste</button>
|
||||
<button class="sort-chip" data-sort="oldest" onclick="loadStarred('oldest')">Älteste</button>
|
||||
@@ -266,6 +294,9 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="starred-search" class="list-search" type="text" placeholder="In Favoriten suchen…" oninput="filterStarred(this.value)">
|
||||
</div>
|
||||
<div id="starred-artists-section"></div>
|
||||
<div id="starred-albums-section"></div>
|
||||
<div id="starred-songs-title" class="search-section-title" style="display:none;margin:18px 0 8px">Songs</div>
|
||||
<div id="starred-songs" class="song-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -329,28 +360,104 @@
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- DISCOVER (Global) -->
|
||||
<!-- DISCOVER (Global) — Deezer search + link importer unified in ONE field -->
|
||||
<div class="page" id="page-discover">
|
||||
<div class="page-header">
|
||||
<div class="page-title"><span class="blue">Global</span> Suche</div>
|
||||
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">Deezer</span>
|
||||
<span class="badge badge-deezer" style="font-size:11px;padding:4px 10px">Deezer</span>
|
||||
</div>
|
||||
<div class="search-wrap" style="padding:0 0 20px">
|
||||
<div class="search-box" style="background:var(--surface);border-color:var(--border2)">
|
||||
<div class="search-wrap" style="padding:0 0 12px">
|
||||
<div style="display:flex;gap:8px">
|
||||
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input id="global-search-input" type="text" placeholder="Künstler, Titel, Album suchen…" oninput="onGlobalSearch(this.value)" style="font-size:15px">
|
||||
<div id="importer-badge" style="display:none;font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
|
||||
<input id="global-search-input" type="text" placeholder="Künstler, Titel, Album — oder Link zum Importieren einfügen…" oninput="onGlobalInput(this.value)" onkeydown="if(event.key==='Enter'){event.preventDefault();tryImporterResolve()}" style="font-size:15px">
|
||||
</div>
|
||||
<button id="importer-btn" class="btn-outline" style="display:none;padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link importer — auto-detects Spotify / SoundCloud / YouTube; shown only when a link is in the field -->
|
||||
<div id="importer-area" style="display:none">
|
||||
<div id="importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:8px">
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#FF0000;font-weight:700">YouTube</span> — Video oder Playlist</div>
|
||||
</div>
|
||||
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
|
||||
</div>
|
||||
|
||||
<!-- hidden inputs kept for compat with spotify.js / soundcloud.js resolvers -->
|
||||
<input id="importer-link-input" type="hidden">
|
||||
<input id="spotify-link-input" type="hidden">
|
||||
<input id="sc-link-input" type="hidden">
|
||||
|
||||
<!-- SPOTIFY RESULTS -->
|
||||
<div id="spotify-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="spotify-preview-inner"></div>
|
||||
<div id="spotify-enrich-status" style="display:none"></div>
|
||||
<div id="spotify-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="spotify-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="spotify-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOUNDCLOUD RESULTS -->
|
||||
<div id="sc-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="sc-preview-inner"></div>
|
||||
<div id="sc-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="sc-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="sc-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YOUTUBE RESULTS -->
|
||||
<div id="yt-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="yt-preview-inner"></div>
|
||||
<div id="yt-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="yt-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="yt-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global-search-results">
|
||||
<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein um loszulegen</p></div>
|
||||
<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein — oder füge einen Spotify-/SoundCloud-/YouTube-Link ein zum Importieren</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEW RELEASES -->
|
||||
<div class="page" id="page-artist-news">
|
||||
<button class="back-btn" id="artist-news-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Favoriten</button>
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
|
||||
</div>
|
||||
<!-- Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind
|
||||
dasselbe Thema. Nicht in EINE Liste gemischt — Veroeffentlichungen liegen hinter,
|
||||
Konzerte vor dem heutigen Tag, chronologisch gemischt waere das nicht zu lesen. -->
|
||||
<div id="artist-news-tabs" class="sort-chips" style="margin-bottom:6px">
|
||||
<button class="sort-chip active" data-tab="releases" onclick="showArtistNewsTab('releases')">Neu erschienen</button>
|
||||
<button class="sort-chip" data-tab="konzerte" onclick="showArtistNewsTab('konzerte')">Konzerte</button>
|
||||
</div>
|
||||
<div id="artist-news-sort" class="sort-chips">
|
||||
<button class="sort-chip active" data-days="90" onclick="setArtistNewsDays(90)">90 Tage</button>
|
||||
<button class="sort-chip" data-days="30" onclick="setArtistNewsDays(30)">30 Tage</button>
|
||||
<button class="sort-chip" data-days="365" onclick="setArtistNewsDays(365)">1 Jahr</button>
|
||||
</div>
|
||||
<div id="artist-news-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="page-new-releases">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neue <em>Releases</em></div>
|
||||
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">Deezer Charts</span>
|
||||
<span class="badge badge-deezer" style="font-size:11px;padding:4px 10px">Deezer Charts</span>
|
||||
</div>
|
||||
<div class="section-header" style="margin-top:0"><span class="section-title">Top Alben weltweit</span></div>
|
||||
<div id="nr-albums" class="grid grid-4"></div>
|
||||
@@ -390,76 +497,19 @@
|
||||
<div id="abs-book-detail-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- SPOTIFY IMPORTER -->
|
||||
<div class="page" id="page-spotify-importer">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Song <em>Importer</em></div>
|
||||
</div>
|
||||
|
||||
<!-- UNIFIED INPUT — auto-detects Spotify vs SoundCloud -->
|
||||
<div class="search-wrap" style="padding:0 0 8px">
|
||||
<div style="display:flex;gap:8px">
|
||||
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
|
||||
<div id="importer-badge" style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
|
||||
<input id="importer-link-input" type="url" placeholder="Spotify, SoundCloud oder YouTube Link einfügen…" style="font-size:14px" oninput="onImporterInput(this.value)" onkeydown="if(event.key==='Enter'){event.preventDefault();resolveImporterLink()}">
|
||||
</div>
|
||||
<button id="importer-btn" class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:4px">
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
|
||||
<div style="display:flex;align-items:center;gap:6px"><span style="color:#FF0000;font-weight:700">YouTube</span> — Video oder Playlist</div>
|
||||
</div>
|
||||
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
|
||||
</div>
|
||||
|
||||
<!-- hidden inputs kept for compat with spotify.js / soundcloud.js -->
|
||||
<input id="spotify-link-input" type="hidden">
|
||||
<input id="sc-link-input" type="hidden">
|
||||
|
||||
<!-- SPOTIFY RESULTS -->
|
||||
<div id="spotify-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="spotify-preview-inner"></div>
|
||||
<div id="spotify-enrich-status" style="display:none"></div>
|
||||
<div id="spotify-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="spotify-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="spotify-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOUNDCLOUD RESULTS -->
|
||||
<div id="sc-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="sc-preview-inner"></div>
|
||||
<div id="sc-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="sc-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="sc-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YOUTUBE RESULTS -->
|
||||
<div id="yt-preview" style="display:none;margin-bottom:8px">
|
||||
<div id="yt-preview-inner"></div>
|
||||
<div id="yt-tracks-section" style="display:none">
|
||||
<div class="section-header" style="margin-top:8px">
|
||||
<span class="section-title" id="yt-tracks-title">Tracks</span>
|
||||
</div>
|
||||
<div id="yt-tracks-list" class="song-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUEUE PANEL -->
|
||||
<div class="queue-panel" id="queue-panel">
|
||||
<div class="queue-head">
|
||||
<div class="queue-title">Warteschlange</div>
|
||||
<button class="queue-close" onclick="toggleQueue()" title="Warteschlange schließen" aria-label="Schließen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="queue-list" id="queue-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -705,6 +755,18 @@
|
||||
<div class="field"><label>Benutzername</label><input id="s-user" type="text" placeholder="admin"></div>
|
||||
<div class="field"><label>Passwort</label><input id="s-pass" type="password" placeholder="(unverändert lassen)"></div>
|
||||
</div>
|
||||
<div class="settings-section" id="s-apk-section" style="display:none">
|
||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
|
||||
Android App
|
||||
</h3>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
|
||||
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
APK herunterladen
|
||||
</a>
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sticky footer -->
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px">
|
||||
@@ -712,6 +774,8 @@
|
||||
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:var(--surface2);color:var(--text2)" onclick="closeSettings()">Abbrechen</button>
|
||||
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
|
||||
</div>
|
||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
|
||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openCompare()">🎧 Musik-Match</button>
|
||||
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1003,8 +1067,8 @@
|
||||
</div>
|
||||
<div class="info-edit-fields">
|
||||
<label class="info-edit-label">Titel<input class="info-edit-input" id="ie-title" type="text" placeholder="Titel"></label>
|
||||
<label class="info-edit-label">Künstler<span class="info-edit-hint">Mehrere Künstler mit <code>;</code> trennen — z.B. <code>Künstler A; Künstler B</code></span><input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler"></label>
|
||||
<label class="info-edit-label">Album-Künstler<span class="info-edit-hint">Mehrere mit <code>,</code> trennen — z.B. <code>Künstler A, Künstler B</code></span><input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler"></label>
|
||||
<label class="info-edit-label">Künstler<span class="info-edit-hint">Mehrere mit <code>;</code> oder <code>,</code> trennen — wird in einzelne Künstler aufgeteilt</span><input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler"></label>
|
||||
<label class="info-edit-label">Album-Künstler<span class="info-edit-hint">Mehrere mit <code>;</code> oder <code>,</code> trennen — bleibt als ein Album-Credit zusammen</span><input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler"></label>
|
||||
<label class="info-edit-label">Album<input class="info-edit-input" id="ie-album" type="text" placeholder="Album"></label>
|
||||
<label class="info-edit-label">Genre<input class="info-edit-input" id="ie-genre" type="text" placeholder="Genre"></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
@@ -1126,6 +1190,12 @@
|
||||
<div id="np-lyrics-view">
|
||||
<div id="np-lyrics-content"></div>
|
||||
</div>
|
||||
<!-- Mitlaufen an/aus. Nur sichtbar, wenn es synchrone Zeilen gibt — bei reinem Text
|
||||
gibt es nichts mitzuführen. -->
|
||||
<button class="np-follow" id="np-follow-btn" onclick="toggleLyricsFolgen()" title="Automatisch mitlaufen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="12 5 12 19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
<span id="np-follow-text">Mitlaufen</span>
|
||||
</button>
|
||||
|
||||
<div id="np-queue-view">
|
||||
<div id="np-queue-list"></div>
|
||||
@@ -1190,10 +1260,6 @@
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
|
||||
Zufällig
|
||||
</button>
|
||||
<button class="bnav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>
|
||||
Importer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"icons": "node scripts/gen-icons.mjs"
|
||||
"icons": "node scripts/gen-icons.mjs",
|
||||
"publish-apk": "node scripts/publish-apk.mjs",
|
||||
"push-images": "node scripts/build-push-images.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.novasonic.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"10:39:A0:0E:D3:93:D3:93:CB:50:5D:C5:0D:73:97:2B:F7:40:7C:F8:08:CC:DB:EE:67:D0:E3:CD:64:AB:01:CF"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -27,5 +27,9 @@
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
],
|
||||
"launch_handler": {
|
||||
"client_mode": "navigate-existing"
|
||||
},
|
||||
"handle_links": "preferred"
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'ns-v56';
|
||||
const CACHE = 'ns-v78';
|
||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy this file to scripts/.env.release (gitignored) and fill in your values.
|
||||
# Used ONLY by `npm run publish-apk` on your DEV machine to upload the built APK to Gitea.
|
||||
#
|
||||
# cp scripts/.env.release.example scripts/.env.release
|
||||
#
|
||||
# GITEA_URL: a Gitea base URL reachable FROM THIS (dev) machine. On your LAN that's the internal
|
||||
# address, e.g. http://192.168.1.10:3000 — NOT the (blocked) external duckdns URL.
|
||||
GITEA_URL=http://192.168.1.10:3000
|
||||
GITEA_REPO=Docker/NovaSonic
|
||||
# A Gitea token with WRITE access (Settings → Applications → scope write:repository).
|
||||
GITEA_TOKEN=paste-your-write-token-here
|
||||
# Fixed release tag the APK lives in (leave as-is unless you changed it on the backend too).
|
||||
GITEA_APK_TAG=apk-latest
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user