Compare commits
75
Commits
569af968c5
...
apk-latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -3,6 +3,7 @@
|
|||||||
# Pre-fills the URLs on the setup screen
|
# Pre-fills the URLs on the setup screen
|
||||||
NAVIDROME_URL=https://music.yourdomain.com
|
NAVIDROME_URL=https://music.yourdomain.com
|
||||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||||
|
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
|
||||||
|
|
||||||
# Absolute path to your Navidrome music library on the host
|
# Absolute path to your Navidrome music library on the host
|
||||||
MUSIC_PATH=/srv/navidrome/music
|
MUSIC_PATH=/srv/navidrome/music
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
*.log
|
*.log
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
scripts/.env.release
|
||||||
+196
-20
@@ -54,11 +54,196 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
| Offline playback | ❌ | ✅ |
|
| Offline playback | ❌ | ✅ |
|
||||||
| Offline detail / manage downloads | ❌ | ✅ |
|
| Offline detail / manage downloads | ❌ | ✅ |
|
||||||
| Player bar on all screens | ✅ | ✅ |
|
| Player bar on all screens | ✅ | ✅ |
|
||||||
|
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
||||||
|
| Listening statistics view | ✅ | ✅ |
|
||||||
|
| Stats cover art (artist/album) | ✅ | ✅ |
|
||||||
|
| Musik-Match (Head-to-Head) | ✅ | ✅ |
|
||||||
|
| Yearly Wrapped | 🔜 | 🔜 |
|
||||||
| Romaji / Japanese sort | ✅ | ❌ |
|
| Romaji / Japanese sort | ✅ | ❌ |
|
||||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.2] — 2026-08-22
|
||||||
|
|
||||||
|
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
|
||||||
|
like Android), concert events become **reliable across web/Android**, several Android gaps close, and
|
||||||
|
**YouTube downloads are recovered** after they broke on two independent fronts.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **In-app updater (Android)** — on launch the app asks the backend (`/api/apk/info`) whether a newer
|
||||||
|
**versionCode** than this build is available and, if so, offers "Laden & installieren": it downloads
|
||||||
|
the APK from `/api/apk` (which proxies the fixed Gitea release) into a single cache file, then hands
|
||||||
|
it to the system installer. No APK pile-up (the cache file is wiped before each download); an update
|
||||||
|
keeps app data / offline downloads (same package + signature = in-place install). The user still
|
||||||
|
confirms the system install and grants "unknown apps" once — Android requires that for sideloading.
|
||||||
|
`publish-apk` now **auto-increments `versionCode`** each build and writes it into the release so the
|
||||||
|
check is a reliable integer compare (commit the bumped `build.gradle.kts`).
|
||||||
|
- **Android APK download in Settings (Web)** — a "🤖 Android App" section in Settings offers the
|
||||||
|
latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button
|
||||||
|
appears only when an APK is available and shows version/size/date. The backend serves it
|
||||||
|
(`GET /api/apk`, metadata at `/api/apk/info`) from one of two sources, in order:
|
||||||
|
1. **A fixed Gitea release** (tag `apk-latest`) whose `.apk` asset you overwrite each build —
|
||||||
|
constant ~30 MB, uploadable from anywhere. The backend fetches it **server-side** over the LAN
|
||||||
|
(`GITEA_URL`/`GITEA_REPO`/`GITEA_TOKEN`/`GITEA_APK_TAG` env), so Gitea can stay internal-only and
|
||||||
|
the token never reaches the browser. Release *assets* live in Gitea's attachment store, not the
|
||||||
|
git history, so `clone`/`pull` stay small.
|
||||||
|
2. **Local fallback** — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional
|
||||||
|
`/data/novasonic.version.txt`).
|
||||||
|
The Android *source* is already in the repo; only build artifacts stay out of git.
|
||||||
|
- **Unified Global Search field (Web)** — the separate Song-Importer page/sidebar entry is gone. The
|
||||||
|
one search field now auto-detects: a **link** (Spotify/SoundCloud/YouTube) opens the importer
|
||||||
|
preview, plain **text** runs a Deezer search. Cleaner left nav, same behaviour as Android.
|
||||||
|
- **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its
|
||||||
|
Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback
|
||||||
|
pauses; the row highlights while playing). Parity with the web importer.
|
||||||
|
- **Scroll & search position remembered on Back (Web + Android)** — going into an album/artist and
|
||||||
|
back now returns you to the same scroll spot with your typed query still there, instead of jumping
|
||||||
|
to the top. Web: each history entry's scroll (and the open local/header search) is snapshotted and
|
||||||
|
restored on Back/Forward; a genuine new navigation still starts at the top. Android: the Alben /
|
||||||
|
Künstler lists and the global/local search-results scroll are hoisted into their ViewModels, so
|
||||||
|
they survive a drill-in/back for as long as you're in that tab.
|
||||||
|
- **Partial album — Play / Shuffle + track menu (Android)** — an incomplete album's downloaded
|
||||||
|
tracks now have **Abspielen** and **Zufall** buttons and a per-row 3-dot menu, so you can still
|
||||||
|
play/shuffle what you have while the rest downloads.
|
||||||
|
- **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing**
|
||||||
|
(Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline),
|
||||||
|
two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's
|
||||||
|
own swipe (skip track) still wins, since it's a child gesture.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
|
||||||
|
returns a partial subset (or none), so back-to-back calls disagreed (web showed 7, a later live
|
||||||
|
call got only 2, Android sometimes showed 0). The web had a private 30-min cache masking this; the
|
||||||
|
cache now lives **on the backend** and **prefers the larger recent result**, so a flaky response
|
||||||
|
can't shrink a good list and every client stays consistent (1 h TTL self-heals).
|
||||||
|
- **Concert date/time (Web + Android)** — shows the **venue-local** time (Ticketmaster's `localDate`/
|
||||||
|
`localTime`) instead of the UTC instant, so a 20:00 show no longer displayed as 08:00. Android
|
||||||
|
parses the naive local datetime (was crashing on zone-less values → missing times).
|
||||||
|
- **Concert rows uneven (Android)** — rebuilt each row with a fixed-width calendar-style date block
|
||||||
|
(day big, month/year + time small), so every row is the same height and the divider/venue align.
|
||||||
|
- **Concert load failures were silent (Android)** — the fetch swallowed errors and showed an empty
|
||||||
|
list. Errors now surface ("Konzerte konnten nicht geladen werden" + reason), with a request
|
||||||
|
timeout and non-2xx treated as a real failure.
|
||||||
|
- **Web importer preview layout** — the SoundCloud track row was missing its grid number-cell, so the
|
||||||
|
4-column `.gtrack-item` grid squeezed the cover into 28px and the title into 52px (title wrapped
|
||||||
|
one character per line). Added the missing cell.
|
||||||
|
- **Header search dropdown covered the sidebar (Web)** — `#header-search-overlay` was
|
||||||
|
`position:fixed; left:0; right:0` (full viewport), overlapping the left sidebar. It's now anchored
|
||||||
|
to the search bar (`position:absolute` inside the wrap), so it drops down at the bar's width and the
|
||||||
|
sidebar stays visible.
|
||||||
|
- **Android artist albums incomplete** — `lookupArtistAlbums` filtered to `record_type == "album"`,
|
||||||
|
dropping EPs and singles that Deezer (and the web app) show. Now returns all, sorted album → EP →
|
||||||
|
single.
|
||||||
|
- **Japanese/CJK albums vanished from the artist discography (Web + Android)** — the album-name
|
||||||
|
normaliser stripped everything except `a-z0-9`, so any Japanese/CJK title collapsed to an empty
|
||||||
|
string. All of an artist's JP singles then normalised identically (`""`), so they de-duplicated
|
||||||
|
into one entry and even "matched" each other — e.g. inabakumori (稲葉曇) showed only 2 of 9 releases,
|
||||||
|
the rest hidden behind one album. The normaliser now keeps Unicode letters/numbers (`\p{L}\p{N}`),
|
||||||
|
so JP titles stay distinct and the full discography (In Library / Unvollständig / Noch nicht
|
||||||
|
heruntergeladen) shows correctly. Same fix applied to the partial-album track match and the
|
||||||
|
Deezer-artist name match.
|
||||||
|
- **Android "Unvollständig" (partial albums) never showed** — Deezer's `/artist/{id}/albums` omits
|
||||||
|
`nb_tracks`, so an owned album's real total was unknown (0) and it never got flagged incomplete;
|
||||||
|
every partly-downloaded album looked complete. The artist VM now resolves the real track count for
|
||||||
|
**matched (owned)** albums via a per-album lookup (like web), so the "Unvollständig" section — with
|
||||||
|
the downloaded/total badge and the downloaded-vs-missing split screen — populates correctly on both
|
||||||
|
the library and Deezer artist pages.
|
||||||
|
- **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill
|
||||||
|
in the artist hero's corner instead of a full-width labelled button.
|
||||||
|
- **Downloads from search/Bibliothek were silent (Android)** — a download started from the global
|
||||||
|
search / Bibliothek-Global tab fired only a transient toast and then registered no
|
||||||
|
success/failure notification (only the importer and artist page did). It now goes through the same
|
||||||
|
app-lifetime `NotificationStore` poll, so it notifies on completion even after you leave the screen.
|
||||||
|
- **YouTube downloads (HTTP 403 + "Signature solving failed")** — two things had broken downloads:
|
||||||
|
1. Recent yt-dlp needs an **external JavaScript runtime** to solve YouTube's signature/nsig
|
||||||
|
challenge — without it, formats were skipped and the media 403'd. The backend now installs
|
||||||
|
**Deno**, which yt-dlp uses automatically.
|
||||||
|
2. A bug in the current yt-dlp **stable** 403'd the `android_vr` client on *every* video
|
||||||
|
([yt-dlp#17456](https://github.com/yt-dlp/yt-dlp/issues/17456), fixed in nightly). The backend
|
||||||
|
now pulls yt-dlp from the **nightly** channel (`pip install --pre 'yt-dlp[default]'`).
|
||||||
|
No cookies, PO-token service, or proxy needed — the earlier 403 that looked like an IP block was
|
||||||
|
this stable bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1] — 2026-08-14
|
||||||
|
|
||||||
|
Builds on the 2.0 listening archive with a **head-to-head "Musik-Match"**, brings the **artist pin**
|
||||||
|
to Android at parity with web, and fixes a batch of same-name-artist and player rough edges.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Musik-Match (Head-to-Head) — Web + Android** — compare two users' listening taste from the
|
||||||
|
shared play-log. New scrobble routes `GET /scrobble/users` (partner picker) and
|
||||||
|
`GET /scrobble/compare?with=` — the match score is the **cosine of the two users' artist-play
|
||||||
|
vectors** (0–100). Shows shared artists (mirrored bars), shared tracks, and each side's unique top
|
||||||
|
artists. Opened from Settings ("🎧 Musik-Match"). Only aggregates are exposed, never one user's raw
|
||||||
|
history.
|
||||||
|
- **Artist pin on Android (parity with web)** — read AND write the shared server-side pins
|
||||||
|
(`/api/artist-overrides`) that fix same-name-artist collisions. A "⚙ Falscher Künstler?/Gepinnt"
|
||||||
|
pill on the artist hero opens a picker (Deezer search + a **paste-a-Deezer-link/id** route, the
|
||||||
|
reliable way when `/search/artist` can't surface a low-profile same-name artist); when pinned, the
|
||||||
|
page enriches straight from the pinned Deezer id (albums / top tracks / fans / image). Pins set on
|
||||||
|
either platform apply on both.
|
||||||
|
- **Swipe between Erkunden sub-tabs (Android)** — a left/right swipe switches Bibliothek ↔ Global,
|
||||||
|
two-way synced with the tab bar (HorizontalPager).
|
||||||
|
- **Similar Artists with profile pictures (Web)** — the artist page's "Ähnliche Künstler" now render
|
||||||
|
as avatar chips, matching the Android app (previously plain text).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A failed direct-link download reported success** — the direct-URL path returned `success` even
|
||||||
|
when no audio was produced (e.g. a YouTube 403), so the job showed "Fertig!" and the UI (web +
|
||||||
|
Android) said the track was added. It now reports failure with a clear message.
|
||||||
|
- **Artist pin — search & similar artists (Web + Android)** — a pinned artist now shows the correct
|
||||||
|
Deezer photo in library search (not the wrong same-name person's), and its "Similar Artists" use
|
||||||
|
Deezer's **related** for the pinned artist instead of Last.fm's list (which was keyed to the
|
||||||
|
merged/wrong Navidrome artist and went "weird").
|
||||||
|
- **Android player — seek broke after seamless track transitions** — skipping through
|
||||||
|
already-buffered tracks left `durationMs` stuck at 0 (the player stayed READY, so `syncState()`
|
||||||
|
wasn't re-called and `ctrl.duration` was still `TIME_UNSET`), so the seek bar snapped playback to
|
||||||
|
the start (or to a stale offset). `durationMs` now self-heals via the ticker + a fallback to the
|
||||||
|
song's known duration. No app restart needed.
|
||||||
|
- **Erkunden swipe polish (Android)** — pre-compose the neighbour page so the first swipe doesn't
|
||||||
|
hitch, drive the tab indicator off the pager so it moves with the swipe, and keep the Top-Alben
|
||||||
|
charts anchored to the top (were vertically centered).
|
||||||
|
- **Web** — the pin picker crashed (`esc()` on a numeric Deezer id) and the pin button moved to the
|
||||||
|
banner's top-right; pinning by a pasted Deezer link/id was added.
|
||||||
|
- **Android** — Settings spacing: the gap now sits below "Musik-Match", grouping it with "Meine
|
||||||
|
Statistiken".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0] — 2026-08-07
|
||||||
|
|
||||||
|
Big change: **NovaSonic gets its own self-hosted, multi-user listening archive.** Navidrome only stores a play *count* and the *last* play date — no timestamped history — so per-day/month/year stats and a yearly "Wrapped" were impossible. Koito and Spindle (the off-the-shelf options) are effectively single-user. Since NovaSonic *is* the player, it now records every counted play into an **isolated scrobble service** with its own database, and shows the results in a **statistics view on web and Android**. This runs **in addition to** the existing Navidrome/Last.fm scrobble — it never replaces it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **New `novasonic-scrobble` service** — a small, isolated container (`node:22-slim` + built-in `node:sqlite`, own volume) that owns the play-log. Kept separate from the download backend on purpose: the backend is heavy (yt-dlp/ffmpeg) and restarts regularly, while an archive needs uptime + data integrity. Routes:
|
||||||
|
- `POST /scrobble/plays` — batch ingest, **idempotent by uuid** (safe offline-retry)
|
||||||
|
- `GET /scrobble/history` — raw chronological log (the archive)
|
||||||
|
- `GET /scrobble/stats` — totals, top artists/albums/tracks, day/month/year series, 24h listening clock (on-the-fly `GROUP BY strftime`, local-tz bucketing)
|
||||||
|
- `DELETE /scrobble/plays?user=` — purge one user's rows (test cleanup)
|
||||||
|
- `GET /scrobble/health` — liveness
|
||||||
|
- **Multi-user**: every request carries the same Subsonic token creds the app already uses; the service verifies them via Navidrome `ping.view` (cached), so the stored `user` is real, not spoofable. No new login.
|
||||||
|
- **Play capture (Web + Android)** — fires at the *existing* counted-play moment (respects the user's `scrobbleMode`, consistent with Last.fm), with an **offline-safe queue** (localStorage on web, a JSON file on Android) flushed as an idempotent batch on start and when connectivity returns.
|
||||||
|
- **Statistics view (Web + Android)** — opened from Settings ("📊 Meine Statistiken"): totals tiles (**Plays** + listening time), a day/month/year play series, top artists/tracks/albums, and a 24h listening clock, with period tabs (Alles / Dieses Jahr / Letzte 30 Tage).
|
||||||
|
- **Cover art in stats (Web + Android)** — top artists show a round artist image, tracks/albums a square cover, resolved client-side from Navidrome (the scrobble service stays cover-agnostic).
|
||||||
|
- **`SCROBBLE_URL`** environment variable (frontend) + a **Scrobble URL** field with a test button in Settings on both platforms.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Backend** — base image bumped `node:20-slim` → **`node:22-slim`** (Node 20 reaches end-of-life April 2026; enables the built-in `node:sqlite`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Downloads** — remix / live / acoustic / instrumental tracks were skipped as duplicates of the base track: duplicate detection stripped *all* bracketed content, collapsing "Song (Remix)" → "Song". Version-marker brackets are now preserved, so a distinct recording downloads (noise brackets like `(feat. X)` / `(Official Video)` are still stripped). Verified against Deezer + unit-tested both directions.
|
||||||
|
- **UI** — dragging the mouse near a field edge (Global search, importer) started selecting the surrounding titles/labels instead of feeling like an app. `user-select` is now disabled on the `#app` shell; real text fields and lyrics stay selectable.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The scrobble subdomain **must be excluded from any SSO / forward-auth gateway** (Authelia/Authentik/etc.), exactly 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`).
|
||||||
|
- Deploy: add `SCROBBLE_URL` to `.env`, `docker compose up -d novasonic-scrobble`, and route port **3710** through your reverse proxy.
|
||||||
|
- Planned next: a yearly **Wrapped** (`/scrobble/wrapped` + story page) and an optional **ListenBrainz relay** — not in this release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.3] — 2026-07-31
|
## [1.3] — 2026-07-31
|
||||||
|
|
||||||
Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere.
|
Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere.
|
||||||
@@ -72,6 +257,7 @@ Big change: **the entire metadata/search stack moved from Apple's iTunes to Deez
|
|||||||
- The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`**
|
- The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`**
|
||||||
- **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading)
|
- **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading)
|
||||||
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
|
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
|
||||||
|
- **Web** — Renamed the leftover `itunes*` frontend identifiers to `deezer*` (`itunesSearch` → `deezerSearch`, `itunesLookup*` → `deezerLookup*`, `itunesData`/`itunesArtistId` → `deezerData`/`deezerArtistId`, the `itunes-album-detail` route/DOM ids, the `badge-itunes` CSS class, …). Purely internal — no behaviour change; only the factual "Apple's iTunes /search 403" comments keep the old name
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android)
|
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android)
|
||||||
@@ -79,8 +265,10 @@ Big change: **the entire metadata/search stack moved from Apple's iTunes to Deez
|
|||||||
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
|
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Android** — After **tabbing out and back into the app** with NowPlaying (or an ABS player) open, pressing back first popped the NavHost **invisibly to Home** and only *then* dismissed the overlay — the focus was lost. Returning from the background re-registers the NavController's own back callback on top of the dispatcher, stealing back-priority from the overlays. The overlay back-callbacks are now re-asserted on `ON_RESUME` (and on navigation), so back closes the overlay first again
|
||||||
|
- **Web + Android** — A finished-download **notification led to the wrong song**: it resolved the album by name via `search3`, and when the exact artist match wasn't found (e.g. the new album isn't scanned yet), it fell back to a same-named album by a **different** artist (or the first result). Several artists share an album title ("I LIKE IT!"), so tapping the notification opened a completely different song. Now the artist must match when known (fuzzy for multi-artist/album-artist drift); otherwise it shows "wird evtl. gerade gescannt" instead of opening a wrong album
|
||||||
|
- **Web** — Downloading a not-yet-owned album from an artist page searched YouTube for "**Unknown Artist** <album>" and failed. Deezer's `/artist/{id}/albums` omits the `artist` object, so the discography cards had an empty artist name → the modal fell back to the literal `"Unknown Artist"`. Fixed by backfilling the known artist name in `deezerLookupArtistAlbums` and defaulting the download modal to **Album** for album cards (was always **Track**)
|
||||||
- **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial)
|
- **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial)
|
||||||
- **Web + Android** — Local Navidrome artist pages with **no artist image** (niche artists like N!GHT that Navidrome's metadata agents have no art for) showed a blank hero. They now fall back to the **matched Deezer artist photo** (Navidrome image → Deezer photo → album cover)
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54`
|
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54`
|
||||||
@@ -211,25 +399,6 @@ Polish release: Android navigation and offline resilience, download accuracy for
|
|||||||
5. MBIDs not set — still missing; not the primary splitting cause
|
5. MBIDs not set — still missing; not the primary splitting cause
|
||||||
|
|
||||||
---
|
---
|
||||||
- **Backend** — Japanese tracks now stored with original Japanese names in folders, filenames, and all main tags (title/artist/album/album\_artist); Romaji goes only into sort tags (TITLESORT / ARTISTSORT / ALBUMARTISTSORT / ALBUMSORT) for sorting and search in Navidrome. Previously, romaji was applied to filenames and tags too, causing name drift, duplicate misses, and wrong iTunes resolutions (e.g. "Hisaishi Yuzuru" instead of "Joe Hisaishi")
|
|
||||||
- **Backend** — Metadata editor: files and album/artist folders are renamed using the original name typed in the form (may be Japanese), not romaji; sort tags still receive the romaji conversion
|
|
||||||
- **Backend** — `album_artist` tag in downloaded tracks now consistently uses `mainArtist` (the iTunes-resolved album artist) rather than the raw per-track artist. This prevents Navidrome from splitting one album into multiple when track artists vary (e.g. compilations)
|
|
||||||
- **Backend (download)** — No-duration cascade now ends after step 4 (same Mittelweg philosophy as the with-duration branch): no title-only or filter-free fallback grabs that pull wrong covers (observed: Yorushika 車窓 → Piano Echoes Shasou)
|
|
||||||
- **Backend (download)** — `--max-filesize 30M` added to all yt-dlp calls; Mittelweg/no-duration steps also cap at 2× expected duration — a 28-min video can no longer be saved as a ~52 MB mp3
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Backend (download)** — Duplicate detection now matches case-insensitively (DJ OKAWARI vs DJ Okawari) and across all album folders under the artist folder, comparing normalized title without track prefix or release/variant suffix — prevents re-downloading songs whose filename drifted between runs
|
|
||||||
- **Backend (download)** — iTunes `collectionArtistName` (album artist) is now captured and passed through the download pipeline as the `album_artist` tag; fixes compilation albums showing "Various Artists" for some tracks and the individual track artist for others
|
|
||||||
- **Metadata editor (autofill)** — iTunes button now fills all available fields: title (`trackName`), artist (`artistName`), album-artist (`collectionArtistName`), album, genre, year, track #, disc #, total tracks, cover
|
|
||||||
- **Metadata editor (autofill)** — MusicBrainz button now fills album-artist (from release artist-credit) and disc # in addition to the existing fields
|
|
||||||
|
|
||||||
### Investigated (Point 4 — album splitting)
|
|
||||||
Primary causes identified:
|
|
||||||
1. Missing `album_artist` tag / inconsistent value across tracks — fixed above (mainArtist + collectionArtistName)
|
|
||||||
2. `tagAlbumArtist` was using `rawArtist` (per-track Spotify name) instead of `mainArtist` — fixed
|
|
||||||
3. Album name comes from iTunes `collectionName` — consistent within a collection, no fix needed
|
|
||||||
4. Year from iTunes `releaseDate` — album-level date, consistent within a collection
|
|
||||||
5. MBIDs not set — still missing; not the primary splitting cause
|
|
||||||
|
|
||||||
## [0.8] — 2026-06-19
|
## [0.8] — 2026-06-19
|
||||||
|
|
||||||
@@ -398,6 +567,10 @@ Primary causes identified:
|
|||||||
## Planned / Backlog
|
## Planned / Backlog
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
|
||||||
|
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
|
||||||
|
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
|
||||||
|
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
|
||||||
- **Web + Android** — **Discover Mixes (auto-download, temporary)** — themed/random mixes for discovering unknown music, Spotify-style (e.g. "Rock", "Charts", "Random Discovery"). Idea:
|
- **Web + Android** — **Discover Mixes (auto-download, temporary)** — themed/random mixes for discovering unknown music, Spotify-style (e.g. "Rock", "Charts", "Random Discovery"). Idea:
|
||||||
- Generate a mix from a theme/genre/charts (sources: Last.fm / ListenBrainz similar + iTunes charts), then **auto-download** the tracks via the yt-dlp backend so they play locally.
|
- Generate a mix from a theme/genre/charts (sources: Last.fm / ListenBrainz similar + iTunes charts), then **auto-download** the tracks via the yt-dlp backend so they play locally.
|
||||||
- **Temporary by default:** tracks **auto-delete after a configurable time** (e.g. 7 days) so the library doesn't fill up with stuff you didn't keep.
|
- **Temporary by default:** tracks **auto-delete after a configurable time** (e.g. 7 days) so the library doesn't fill up with stuff you didn't keep.
|
||||||
@@ -406,6 +579,7 @@ Primary causes identified:
|
|||||||
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
|
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
|
||||||
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
|
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
|
||||||
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
|
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
|
||||||
|
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page.
|
||||||
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
|
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
|
||||||
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
|
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
|
||||||
- Ties into the importer: a "download this new release" action straight from the notification.
|
- Ties into the importer: a "download this new release" action straight from the notification.
|
||||||
@@ -414,6 +588,8 @@ Primary causes identified:
|
|||||||
- **Android** — Romaji / Japanese sort support
|
- **Android** — Romaji / Japanese sort support
|
||||||
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
||||||
- **Web + Android** — yt-dlp backend update / maintenance
|
- **Web + Android** — yt-dlp backend update / maintenance
|
||||||
|
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
|
||||||
|
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
|
||||||
|
|
||||||
### Known Bugs
|
### Known Bugs
|
||||||
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
|
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
|
||||||
|
|||||||
@@ -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`,
|
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||||||
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||||||
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
`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
|
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||||||
metadata editor are **not** in the Android app).
|
metadata editor are **not** in the Android app).
|
||||||
|
|
||||||
@@ -104,6 +105,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
|
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||||||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
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.
|
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
|
- **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
|
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**
|
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||||||
@@ -115,6 +127,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
|
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
`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
|
## Android offline system
|
||||||
|
|
||||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||||
@@ -132,6 +182,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**
|
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
`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.
|
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
|
- **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
|
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
|
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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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/
|
├── src/
|
||||||
│ ├── player/
|
│ ├── player/
|
||||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
│ │ ├── 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/
|
│ ├── views/
|
||||||
│ │ ├── detail.js # Artist / album detail pages
|
│ │ ├── detail.js # Artist / album detail pages
|
||||||
│ │ ├── views.js # Navigation, page loaders, search
|
│ │ ├── views.js # Navigation, page loaders, search
|
||||||
@@ -48,6 +50,7 @@ NovaSonic/
|
|||||||
│ │ ├── metadata.js # Metadata editor
|
│ │ ├── metadata.js # Metadata editor
|
||||||
│ │ ├── modals.js # Song info, events modals
|
│ │ ├── modals.js # Song info, events modals
|
||||||
│ │ ├── notify.js # Toast notifications
|
│ │ ├── notify.js # Toast notifications
|
||||||
|
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||||
│ │ └── settings.js # Settings page
|
│ │ └── settings.js # Settings page
|
||||||
│ ├── state.js # Shared mutable state (S object)
|
│ ├── state.js # Shared mutable state (S object)
|
||||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||||
@@ -63,6 +66,8 @@ NovaSonic/
|
|||||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||||
│ └── romaji.js # Japanese → romaji search index
|
│ └── 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)
|
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||||
@@ -84,7 +89,7 @@ NovaSonic/
|
|||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── nginx.conf
|
├── 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 +109,7 @@ Edit `.env`:
|
|||||||
```env
|
```env
|
||||||
NAVIDROME_URL=https://music.yourdomain.com
|
NAVIDROME_URL=https://music.yourdomain.com
|
||||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||||
|
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||||
|
|
||||||
MUSIC_PATH=/srv/navidrome/music
|
MUSIC_PATH=/srv/navidrome/music
|
||||||
|
|
||||||
@@ -120,7 +126,7 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend on port 8080, backend on port 3700. Route both through your reverse proxy.
|
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
|
### 3. First launch
|
||||||
|
|
||||||
@@ -128,6 +134,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
|
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||||
3. Optionally connect Audiobookshelf in Settings
|
3. Optionally connect Audiobookshelf in Settings
|
||||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
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 +146,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 |
|
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||||
| `BACKEND_URL` | Pre-fills the download backend 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
|
### Backend container
|
||||||
|
|
||||||
@@ -151,6 +159,14 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
|||||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||||
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
| `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
|
## Development
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<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
|
<application
|
||||||
android:name=".NovaSonicApp"
|
android:name=".NovaSonicApp"
|
||||||
@@ -44,6 +46,17 @@
|
|||||||
</service>
|
</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 -->
|
<!-- Android Auto -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
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.AbsPlayerController
|
||||||
import com.novasonic.app.player.PlayerController
|
import com.novasonic.app.player.PlayerController
|
||||||
import com.novasonic.app.player.PreviewController
|
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() {
|
class NovaSonicApp : Application() {
|
||||||
|
|
||||||
@@ -27,6 +31,10 @@ class NovaSonicApp : Application() {
|
|||||||
playerController = PlayerController(applicationContext, repository)
|
playerController = PlayerController(applicationContext, repository)
|
||||||
playerController.connect()
|
playerController.connect()
|
||||||
absPlayerController = AbsPlayerController(applicationContext)
|
absPlayerController = AbsPlayerController(applicationContext)
|
||||||
|
// 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.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.engine.android.*
|
import io.ktor.client.engine.android.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
|
|||||||
object EventsClient {
|
object EventsClient {
|
||||||
|
|
||||||
private val http = HttpClient(Android) {
|
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) {
|
install(ContentNegotiation) {
|
||||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,24 @@ object ItunesClient {
|
|||||||
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
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> {
|
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||||
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
||||||
parameter("limit", 200)
|
parameter("limit", 200)
|
||||||
}.body()
|
}.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. */
|
/** Top tracks for a Deezer artist. */
|
||||||
@@ -80,6 +92,18 @@ object ItunesClient {
|
|||||||
return tracks.data.map { it.toItunesItem() }
|
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. */
|
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
|
||||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||||
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ object ClientHolder {
|
|||||||
class MusicRepository(private val context: Context) {
|
class MusicRepository(private val context: Context) {
|
||||||
|
|
||||||
private val prefs = PrefsStore(context)
|
private val prefs = PrefsStore(context)
|
||||||
|
private val playLog = PlayLogClient(context)
|
||||||
|
|
||||||
private suspend fun api(): SubsonicClient =
|
private suspend fun api(): SubsonicClient =
|
||||||
ClientHolder.get(prefs.credentials.first())
|
ClientHolder.get(prefs.credentials.first())
|
||||||
@@ -56,6 +57,49 @@ class MusicRepository(private val context: Context) {
|
|||||||
suspend fun star(id: String) = api().star(id)
|
suspend fun star(id: String) = api().star(id)
|
||||||
suspend fun unstar(id: String) = api().unstar(id)
|
suspend fun unstar(id: String) = api().unstar(id)
|
||||||
suspend fun scrobble(id: String) = api().scrobble(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 getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||||
suspend fun getGenres() = api().getGenres()
|
suspend fun getGenres() = api().getGenres()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class PrefsStore(private val context: Context) {
|
|||||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||||
|
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
|
||||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||||
val KEY_THEME = stringPreferencesKey("theme")
|
val KEY_THEME = stringPreferencesKey("theme")
|
||||||
@@ -40,6 +41,7 @@ class PrefsStore(private val context: Context) {
|
|||||||
val password: String = "",
|
val password: String = "",
|
||||||
val lastFmKey: String = "",
|
val lastFmKey: String = "",
|
||||||
val backendUrl: String = "",
|
val backendUrl: String = "",
|
||||||
|
val scrobbleUrl: String = "",
|
||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
@@ -70,6 +72,7 @@ class PrefsStore(private val context: Context) {
|
|||||||
password = prefs[KEY_PASSWORD] ?: "",
|
password = prefs[KEY_PASSWORD] ?: "",
|
||||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||||
|
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
|
||||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||||
@@ -104,6 +107,7 @@ class PrefsStore(private val context: Context) {
|
|||||||
prefs[KEY_PASSWORD] = settings.password
|
prefs[KEY_PASSWORD] = settings.password
|
||||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||||
|
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
|
||||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||||
prefs[KEY_THEME] = settings.theme
|
prefs[KEY_THEME] = settings.theme
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class MusicService : MediaLibraryService() {
|
|||||||
// Pseudo-items for play-all / shuffle at top of each list
|
// Pseudo-items for play-all / shuffle at top of each list
|
||||||
const val PLAY_ALL_PREFIX = "playall:"
|
const val PLAY_ALL_PREFIX = "playall:"
|
||||||
const val SHUFFLE_PREFIX = "shuffle:"
|
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)
|
// ABS session commands (handled via onCustomCommand)
|
||||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||||
@@ -349,6 +352,10 @@ class MusicService : MediaLibraryService() {
|
|||||||
.build()
|
.build()
|
||||||
).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
|
else -> null
|
||||||
}
|
}
|
||||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||||
@@ -360,42 +367,78 @@ class MusicService : MediaLibraryService() {
|
|||||||
return settable
|
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(
|
override fun onAddMediaItems(
|
||||||
mediaSession: MediaSession,
|
mediaSession: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
mediaItems: MutableList<MediaItem>,
|
mediaItems: MutableList<MediaItem>,
|
||||||
): ListenableFuture<MutableList<MediaItem>> {
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
val settable = SettableFuture.create<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 {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val resolved = mediaItems.flatMap { item ->
|
val (list, idx) = expandForPlayback(mediaItems)
|
||||||
when {
|
// A single browse tap expands into a full list → start at the tapped index.
|
||||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
// Otherwise honour the caller's startIndex.
|
||||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
val expanded = list.size != mediaItems.size
|
||||||
}
|
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||||
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())
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
settable.set(mediaItems)
|
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return settable
|
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> {
|
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||||
@@ -432,16 +475,16 @@ class MusicService : MediaLibraryService() {
|
|||||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||||
FAVORITES_ID -> {
|
FAVORITES_ID -> {
|
||||||
val songs = repo.getStarred().song
|
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 {
|
else -> when {
|
||||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||||
val album = repo.getAlbum(parentId.removePrefix(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) -> {
|
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||||
val pl = repo.getPlaylist(parentId.removePrefix(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()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
@@ -449,6 +492,13 @@ class MusicService : MediaLibraryService() {
|
|||||||
return ImmutableList.copyOf(items)
|
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) =
|
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||||
MediaItem.Builder()
|
MediaItem.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
|
|||||||
@@ -227,11 +227,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(500)
|
delay(500)
|
||||||
val ctrl = controller ?: break
|
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() {
|
private fun stopTicker() {
|
||||||
tickerJob?.cancel()
|
tickerJob?.cancel()
|
||||||
tickerJob = null
|
tickerJob = null
|
||||||
@@ -245,6 +259,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
runCatching { repo.scrobble(song.id) }
|
runCatching { repo.scrobble(song.id) }
|
||||||
|
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +285,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
currentSong = queue.getOrNull(idx),
|
currentSong = queue.getOrNull(idx),
|
||||||
currentIndex = idx,
|
currentIndex = idx,
|
||||||
positionMs = ctrl.currentPosition,
|
positionMs = ctrl.currentPosition,
|
||||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
durationMs = resolveDurationMs(ctrl),
|
||||||
queue = queue,
|
queue = queue,
|
||||||
repeatMode = ctrl.repeatMode,
|
repeatMode = ctrl.repeatMode,
|
||||||
shuffled = isManuallyShuffled,
|
shuffled = isManuallyShuffled,
|
||||||
@@ -314,7 +329,11 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
||||||
if (mediaId.startsWith("abs:")) return
|
if (mediaId.startsWith("abs:")) return
|
||||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: 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)
|
scheduleScrobble(song, dur)
|
||||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||||
prefetchLyrics(song)
|
prefetchLyrics(song)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.*
|
import androidx.navigation.*
|
||||||
import androidx.navigation.compose.*
|
import androidx.navigation.compose.*
|
||||||
@@ -78,6 +80,29 @@ fun AppNavigation() {
|
|||||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
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
|
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
|
||||||
// cancels any active preview so the two don't overlap.
|
// cancels any active preview so the two don't overlap.
|
||||||
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
||||||
@@ -112,7 +137,15 @@ fun AppNavigation() {
|
|||||||
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
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 {}
|
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||||
@@ -120,7 +153,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(showAbsPlayer, backDispatcher) {
|
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||||
@@ -128,7 +161,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(selectedAbsItem, backDispatcher) {
|
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||||
@@ -148,7 +181,6 @@ fun AppNavigation() {
|
|||||||
|
|
||||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentRoute = currentBackEntry?.destination?.route
|
val currentRoute = currentBackEntry?.destination?.route
|
||||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||||
|
|
||||||
@@ -156,7 +188,7 @@ fun AppNavigation() {
|
|||||||
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
|
// 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
|
// — 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).
|
// 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
|
val action = exploreOverlayBack
|
||||||
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
@@ -378,7 +410,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(
|
composable(
|
||||||
"album/{albumId}",
|
"album/{albumId}",
|
||||||
@@ -576,13 +629,19 @@ fun AppNavigation() {
|
|||||||
val res = repo.search(n.navAlbum)
|
val res = repo.search(n.navAlbum)
|
||||||
val albums = res.album
|
val albums = res.album
|
||||||
val norm = { s: String -> s.lowercase().trim() }
|
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 {
|
val match = albums.firstOrNull {
|
||||||
norm(it.name) == norm(n.navAlbum) &&
|
val aa = norm(it.artist ?: "")
|
||||||
(n.navArtist.isBlank() || norm(it.artist ?: "") == norm(n.navArtist))
|
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
|
||||||
} ?: albums.firstOrNull { norm(it.name) == norm(n.navAlbum) }
|
(aa == na || aa.contains(na) || na.contains(aa))
|
||||||
?: albums.firstOrNull()
|
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
navController.navigate("album/${match.id}")
|
navController.navigate("album/${match.id}")
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.novasonic.app.api.model.ConcertEvent
|
import com.novasonic.app.api.model.ConcertEvent
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -31,6 +32,7 @@ fun ConcertsSheet(
|
|||||||
artistName: String,
|
artistName: String,
|
||||||
events: List<ConcertEvent>,
|
events: List<ConcertEvent>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
eventsError: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -47,17 +49,25 @@ fun ConcertsSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (events.isEmpty()) {
|
if (events.isEmpty()) {
|
||||||
Box(
|
Column(
|
||||||
contentAlignment = Alignment.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 40.dp),
|
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Keine Konzerte angekündigt",
|
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
|
||||||
fontFamily = Outfit, fontSize = 14.sp,
|
fontFamily = Outfit, fontSize = 14.sp,
|
||||||
color = NsOnSurface2,
|
color = NsOnSurface2,
|
||||||
)
|
)
|
||||||
|
if (eventsError != null) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
eventsError,
|
||||||
|
fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface2.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -78,7 +88,7 @@ fun ConcertsSheet(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
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 venue = event.venue?.name.orEmpty()
|
||||||
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
||||||
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
||||||
@@ -92,26 +102,33 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
|||||||
.background(NsSurface2)
|
.background(NsSurface2)
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
.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(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.width(52.dp),
|
modifier = Modifier.width(56.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
dateStr,
|
w.day,
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
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(
|
Text(
|
||||||
timeStr,
|
w.monthYear,
|
||||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
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))
|
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))
|
Spacer(Modifier.width(14.dp))
|
||||||
|
|
||||||
// Venue
|
// 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 {
|
return try {
|
||||||
val zdt = ZonedDateTime.parse(datetime)
|
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
|
||||||
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
|
// values ("…Z") still parse via the ZonedDateTime fallback.
|
||||||
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
|
val ldt = runCatching { LocalDateTime.parse(datetime) }
|
||||||
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
|
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
|
||||||
date to time
|
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) {
|
} catch (_: Exception) {
|
||||||
datetime.take(10) to ""
|
EventWhen(datetime.take(10), "", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ import com.novasonic.app.ui.component.ItunesDownloadSheet
|
|||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
@@ -66,6 +67,7 @@ fun ArtistDetailScreen(
|
|||||||
val vm: ArtistViewModel = viewModel()
|
val vm: ArtistViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||||
@@ -90,9 +92,10 @@ fun ArtistDetailScreen(
|
|||||||
val artist = state.artist
|
val artist = state.artist
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
ConcertsSheet(
|
ConcertsSheet(
|
||||||
artistName = artist.name,
|
artistName = artist.name,
|
||||||
events = state.events,
|
events = state.events,
|
||||||
onDismiss = vm::dismissEventsSheet,
|
onDismiss = vm::dismissEventsSheet,
|
||||||
|
eventsError = state.eventsError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,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) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
@@ -137,6 +156,7 @@ fun ArtistDetailScreen(
|
|||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = vm::showDownloadSheet,
|
onDownload = vm::showDownloadSheet,
|
||||||
onBack = vm::dismissPartialAlbum,
|
onBack = vm::dismissPartialAlbum,
|
||||||
|
onShowContextMenu = onShowContextMenu,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,10 +238,10 @@ fun ArtistDetailScreen(
|
|||||||
|
|
||||||
// Hero card
|
// Hero card
|
||||||
item {
|
item {
|
||||||
// Navidrome's own artist image first; if its agents have none (niche artists like N!GHT),
|
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||||
// fall back to the matched Deezer artist photo, then the album cover.
|
// Navidrome/Last.fm image is the wrong person).
|
||||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||||
?: state.deezerImage
|
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||||
?: coverUrl(artist.coverArt)
|
?: coverUrl(artist.coverArt)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -237,6 +257,20 @@ fun ArtistDetailScreen(
|
|||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
// "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(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -293,11 +327,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
|
val similarArtists = state.info?.similarArtist
|
||||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||||
?: emptyList()
|
?: 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 {
|
item {
|
||||||
SectionLabel("Similar Artists")
|
SectionLabel("Similar Artists")
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -632,3 +707,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,345 @@
|
|||||||
|
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),
|
||||||
|
) {
|
||||||
|
ScoreRing(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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ScoreRing(d: CompareResult) {
|
||||||
|
val score = d.matchScore.coerceIn(0, 100)
|
||||||
|
val verdict = when {
|
||||||
|
score >= 80 -> "Seelenverwandte 🎯"
|
||||||
|
score >= 60 -> "Starkes Match 🔥"
|
||||||
|
score >= 40 -> "Solide Überschneidung 👍"
|
||||||
|
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂"
|
||||||
|
else -> "Ziemlich unterschiedlich 🌗"
|
||||||
|
}
|
||||||
|
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) {
|
||||||
|
val accent = NsAccent
|
||||||
|
val track = NsSurface2
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val stroke = 14.dp.toPx()
|
||||||
|
val inset = stroke / 2
|
||||||
|
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||||
|
drawArc(
|
||||||
|
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||||
|
style = Stroke(width = stroke),
|
||||||
|
)
|
||||||
|
drawArc(
|
||||||
|
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||||
|
style = Stroke(width = stroke),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface)
|
||||||
|
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -113,6 +115,7 @@ fun ExploreScreen(
|
|||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = vm::showDownloadSheet,
|
onDownload = vm::showDownloadSheet,
|
||||||
onBack = vm::dismissPartialAlbum,
|
onBack = vm::dismissPartialAlbum,
|
||||||
|
onShowContextMenu = onShowContextMenu,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
albumTracks != null && albumItem != null -> {
|
albumTracks != null && albumItem != null -> {
|
||||||
@@ -148,22 +151,35 @@ fun ExploreScreen(
|
|||||||
return
|
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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
// ── 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(
|
TabRow(
|
||||||
selectedTabIndex = state.tab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = NsAccent,
|
contentColor = NsAccent,
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == ExploreTab.LIBRARY,
|
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
|
||||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||||
text = { Text("Bibliothek", maxLines = 1) },
|
text = { Text("Bibliothek", maxLines = 1) },
|
||||||
selectedContentColor = NsAccent,
|
selectedContentColor = NsAccent,
|
||||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == ExploreTab.GLOBAL,
|
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
|
||||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||||
text = { Text("Erkunden", maxLines = 1) },
|
text = { Text("Erkunden", maxLines = 1) },
|
||||||
selectedContentColor = NsAccent,
|
selectedContentColor = NsAccent,
|
||||||
@@ -230,26 +246,38 @@ fun ExploreScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────────────────────
|
// ── Content (swipeable) ────────────────────────────────────────────────
|
||||||
when (state.tab) {
|
HorizontalPager(
|
||||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
state = pagerState,
|
||||||
query = state.query,
|
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
|
||||||
coverUrl = coverUrl,
|
// builds on first drag.
|
||||||
playerState = playerState,
|
beyondViewportPageCount = 1,
|
||||||
onSongPlay = onSongPlay,
|
modifier = Modifier.weight(1f),
|
||||||
onAlbumClick = onAlbumClick,
|
) { page ->
|
||||||
onArtistClick = onArtistClick,
|
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
|
||||||
onShowContextMenu = onShowContextMenu,
|
// vertically centered in the page.
|
||||||
)
|
Box(Modifier.fillMaxSize()) {
|
||||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
when (page) {
|
||||||
state = state,
|
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
|
||||||
vm = vm,
|
query = state.query,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl,
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
onAlbumClick = onAlbumClick,
|
onSongPlay = onSongPlay,
|
||||||
onSongPlay = onSongPlay,
|
onAlbumClick = onAlbumClick,
|
||||||
onDownload = vm::showDownloadSheet,
|
onArtistClick = onArtistClick,
|
||||||
)
|
onShowContextMenu = onShowContextMenu,
|
||||||
|
)
|
||||||
|
else -> GlobalExploreContent(
|
||||||
|
state = state,
|
||||||
|
vm = vm,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
playerState = playerState,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onSongPlay = onSongPlay,
|
||||||
|
onDownload = vm::showDownloadSheet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +317,7 @@ private fun LibrarySearchContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.listState) {
|
||||||
val artists = state.searchResult?.artist ?: emptyList()
|
val artists = state.searchResult?.artist ?: emptyList()
|
||||||
val albums = state.searchResult?.album ?: emptyList()
|
val albums = state.searchResult?.album ?: emptyList()
|
||||||
val songs = state.searchResult?.song ?: emptyList()
|
val songs = state.searchResult?.song ?: emptyList()
|
||||||
@@ -310,9 +338,11 @@ private fun LibrarySearchContent(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { a ->
|
items(artists, key = { it.id }) { a ->
|
||||||
// search3 artist results usually omit coverArt → fall back to the artist
|
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
|
||||||
// id, which Navidrome's getCoverArt accepts and resolves to the artist image.
|
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
|
||||||
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
// 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))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -431,7 +461,7 @@ private fun GlobalExploreContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.searchListState) {
|
||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(16.dp)) }
|
modifier = Modifier.padding(16.dp)) }
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Download
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -205,7 +207,12 @@ fun ImporterScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemsIndexed(r.tracks) { idx, track ->
|
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 +265,12 @@ private fun JobProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
private fun ImportTrackRow(
|
||||||
|
num: Int,
|
||||||
|
track: ImportTrack,
|
||||||
|
isPreviewing: Boolean,
|
||||||
|
onPreview: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
@@ -272,11 +284,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
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()) {
|
if (!track.artistName.isNullOrBlank()) {
|
||||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
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
|
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||||
|
|
||||||
private fun normAlbum(s: String?): String =
|
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(
|
data class ArtistAlbumCategories(
|
||||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||||
@@ -68,6 +70,10 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
match == null -> notDl.add(ia)
|
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 -> {
|
iCount > match.songCount -> {
|
||||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -79,16 +81,28 @@ fun LibraryScreen(
|
|||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = state.tab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = NsAccent,
|
contentColor = NsAccent,
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEach { tab ->
|
LibraryTab.entries.forEach { tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == tab,
|
selected = pagerState.currentPage == tab.ordinal,
|
||||||
onClick = { vm.selectTab(tab) },
|
onClick = { vm.selectTab(tab) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -108,16 +122,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 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.
|
// 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) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
}
|
}
|
||||||
return@Column
|
} else when (pageTab) {
|
||||||
}
|
|
||||||
|
|
||||||
when (state.tab) {
|
|
||||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||||
// Sort chips
|
// Sort chips
|
||||||
androidx.compose.foundation.lazy.LazyRow(
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
@@ -153,7 +166,7 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val albumGridState = rememberLazyGridState()
|
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
|
||||||
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = albumGridState,
|
state = albumGridState,
|
||||||
@@ -176,7 +189,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LibraryTab.ARTISTS -> {
|
LibraryTab.ARTISTS -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||||
// Map letter → flat LazyColumn index of its header item
|
// Map letter → flat LazyColumn index of its header item
|
||||||
@@ -388,9 +401,10 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end HorizontalPager
|
||||||
} // end Column
|
} // end Column
|
||||||
|
|
||||||
if (state.tab == LibraryTab.PLAYLISTS) {
|
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showCreateDialog = true },
|
onClick = { showCreateDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.verticalScroll
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -76,6 +78,18 @@ fun NowPlayingScreen(
|
|||||||
val song = state.currentSong
|
val song = state.currentSong
|
||||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||||
|
|
||||||
|
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
|
||||||
|
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
|
||||||
|
// child pointerInput and consumes the drag before the pager sees it.
|
||||||
|
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
|
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
||||||
val lyricsVm: LyricsViewModel = viewModel()
|
val lyricsVm: LyricsViewModel = viewModel()
|
||||||
LaunchedEffect(song?.id) {
|
LaunchedEffect(song?.id) {
|
||||||
@@ -178,7 +192,7 @@ fun NowPlayingScreen(
|
|||||||
.padding(3.dp),
|
.padding(3.dp),
|
||||||
) {
|
) {
|
||||||
NpTab.entries.forEach { t ->
|
NpTab.entries.forEach { t ->
|
||||||
val sel = tab == t
|
val sel = pagerState.currentPage == t.ordinal
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -210,8 +224,18 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) { page ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
when (NpTab.entries[page]) {
|
||||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.PLAYER) {
|
NpTab.PLAYER -> {
|
||||||
var dragX by remember { mutableStateOf(0f) }
|
var dragX by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
@@ -401,7 +425,7 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.LYRICS && song != null) {
|
NpTab.LYRICS -> if (song != null) {
|
||||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||||
val lyricsListState = rememberLazyListState()
|
val lyricsListState = rememberLazyListState()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -495,7 +519,7 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||||
if (tab == NpTab.QUEUE) {
|
NpTab.QUEUE -> {
|
||||||
// Radio mode toggle
|
// Radio mode toggle
|
||||||
if (onToggleRadio != null) {
|
if (onToggleRadio != null) {
|
||||||
Row(
|
Row(
|
||||||
@@ -593,6 +617,9 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end when
|
||||||
|
} // end inner Column
|
||||||
|
} // end HorizontalPager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Download
|
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.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -27,7 +30,9 @@ import com.novasonic.app.ui.theme.*
|
|||||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||||
|
|
||||||
private fun normTrack(s: String?) =
|
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
|
@Composable
|
||||||
fun PartialAlbumScreen(
|
fun PartialAlbumScreen(
|
||||||
@@ -39,6 +44,7 @@ fun PartialAlbumScreen(
|
|||||||
onPreview: (ItunesItem) -> Unit,
|
onPreview: (ItunesItem) -> Unit,
|
||||||
onDownload: (ItunesItem) -> Unit,
|
onDownload: (ItunesItem) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val ia = partialAlbum.itunesAlbum
|
val ia = partialAlbum.itunesAlbum
|
||||||
val nd = partialAlbum.ndAlbum
|
val nd = partialAlbum.ndAlbum
|
||||||
@@ -94,6 +100,50 @@ fun PartialAlbumScreen(
|
|||||||
.sortedBy { it.trackNumber ?: 999 }
|
.sortedBy { it.trackNumber ?: 999 }
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
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.shuffled(), 0) }
|
||||||
|
.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 ─────────────────────────────────────────────
|
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||||
if (downloadedSongs.isNotEmpty()) {
|
if (downloadedSongs.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
@@ -111,6 +161,7 @@ fun PartialAlbumScreen(
|
|||||||
starred = song.starred != null,
|
starred = song.starred != null,
|
||||||
onPlay = { onPlay(downloadedSongs, idx) },
|
onPlay = { onPlay(downloadedSongs, idx) },
|
||||||
onStarToggle = {},
|
onStarToggle = {},
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen() {
|
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||||
val vm: SettingsViewModel = viewModel()
|
val vm: SettingsViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -80,7 +80,23 @@ fun SettingsScreen() {
|
|||||||
color = NsOnSurface,
|
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) ────────────────────────────────────────────────
|
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
|
|
||||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||||
@@ -186,6 +202,14 @@ fun SettingsScreen() {
|
|||||||
)
|
)
|
||||||
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 ─────────────────
|
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||||
object NsColors {
|
object NsColors {
|
||||||
|
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
|
||||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||||
var bg by mutableStateOf(Color(0xFF070D14))
|
var bg by mutableStateOf(Color(0xFF070D14))
|
||||||
@@ -88,6 +89,7 @@ val NS_THEMES = listOf(
|
|||||||
|
|
||||||
fun applyNsTheme(themeId: String) {
|
fun applyNsTheme(themeId: String) {
|
||||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||||
|
NsColors.themeId = t.id
|
||||||
NsColors.accent = t.accent
|
NsColors.accent = t.accent
|
||||||
NsColors.accentDim = t.accentDim
|
NsColors.accentDim = t.accentDim
|
||||||
NsColors.bg = t.bg
|
NsColors.bg = t.bg
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.novasonic.app.api.EventsClient
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.ItunesClient
|
import com.novasonic.app.api.ItunesClient
|
||||||
import com.novasonic.app.api.model.Artist
|
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.ConcertEvent
|
||||||
import com.novasonic.app.api.model.ArtistInfo
|
import com.novasonic.app.api.model.ArtistInfo
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
@@ -40,14 +41,23 @@ data class ArtistUiState(
|
|||||||
val partialAlbum: PartialAlbumState? = null,
|
val partialAlbum: PartialAlbumState? = null,
|
||||||
// Concerts
|
// Concerts
|
||||||
val events: List<ConcertEvent> = emptyList(),
|
val events: List<ConcertEvent> = emptyList(),
|
||||||
|
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
|
||||||
val showEventsSheet: Boolean = false,
|
val showEventsSheet: Boolean = false,
|
||||||
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||||
val fanCount: Long? = null,
|
val fanCount: Long? = null,
|
||||||
// Deezer artist photo — hero fallback when Navidrome's agents have no image (niche artists)
|
|
||||||
val deezerImage: String? = null,
|
|
||||||
// Preview + download
|
// Preview + download
|
||||||
val previewingId: Long? = null,
|
val previewingId: Long? = null,
|
||||||
val downloadSheetItem: ItunesItem? = 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) {
|
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||||
@@ -86,19 +96,30 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
val info = infoDef.await()
|
val info = infoDef.await()
|
||||||
|
|
||||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||||
val top = topDef.await()
|
// 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(
|
_state.value = ArtistUiState(
|
||||||
artist = artist, info = info, topSongs = top,
|
artist = artist, info = info, topSongs = top,
|
||||||
isLoading = false, itunesLoading = true,
|
isLoading = false, itunesLoading = true,
|
||||||
|
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch iTunes + events in parallel without blocking the main screen
|
// Fetch iTunes + events in parallel without blocking the main screen
|
||||||
launch { loadItunesData(artist.name) }
|
launch { loadItunesData(artist.name, pin) }
|
||||||
launch {
|
launch {
|
||||||
val backendUrl = repo.getBackendUrl()
|
val backendUrl = repo.getBackendUrl()
|
||||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
|
||||||
_state.value = _state.value.copy(events = ev)
|
.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) {
|
} catch (e: Exception) {
|
||||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||||
@@ -106,27 +127,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 {
|
val searchResp = runCatching {
|
||||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
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
|
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
|
||||||
// one — disambiguates same-name artists (5x "Joji").
|
// 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 = (
|
val iArtist = (
|
||||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
artists.firstOrNull { norm(it.artistName) == q }
|
||||||
?: artists.find {
|
?: artists.firstOrNull {
|
||||||
val n = it.artistName ?: return@find false
|
val n = it.artistName ?: return@firstOrNull false
|
||||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||||
}
|
}
|
||||||
?: artists.firstOrNull()
|
?: artists.firstOrNull()
|
||||||
)
|
)
|
||||||
val iArtistId = iArtist?.artistId
|
val iArtistId = iArtist?.artistId
|
||||||
if (iArtist != null) _state.value = _state.value.copy(
|
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||||
fanCount = iArtist.fanCount ?: _state.value.fanCount,
|
|
||||||
deezerImage = iArtist.artworkUrl100?.takeIf { it.isNotBlank() } ?: _state.value.deezerImage,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (iArtistId != null) {
|
if (iArtistId != null) {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
@@ -138,11 +181,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
itunesLoading = false,
|
itunesLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
enrichPartialCounts()
|
||||||
} else {
|
} else {
|
||||||
_state.value = _state.value.copy(itunesLoading = false)
|
_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) ──────────────────────────────
|
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||||
|
|
||||||
fun loadSubAlbum(album: ItunesItem) {
|
fun loadSubAlbum(album: ItunesItem) {
|
||||||
@@ -275,6 +348,91 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
|
|
||||||
fun stopPreview() = preview.stop()
|
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
|
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||||
// global bar) after leaving this screen.
|
// 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.ItunesItem
|
||||||
import com.novasonic.app.api.model.ItunesRssEntry
|
import com.novasonic.app.api.model.ItunesRssEntry
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import com.novasonic.app.api.model.artworkHd
|
import com.novasonic.app.api.model.artworkHd
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -75,6 +77,9 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||||
val state: StateFlow<ExploreUiState> = _state
|
val state: StateFlow<ExploreUiState> = _state
|
||||||
|
|
||||||
|
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
|
||||||
|
val searchListState = LazyListState()
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -188,19 +193,56 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else 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 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()
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
artistDetail = ItunesArtistState(
|
artistDetail = ItunesArtistState(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
ndAlbums = ndAlbumsD.await(),
|
ndAlbums = ndAlbums,
|
||||||
iAlbums = iAlbumsD.await(),
|
iAlbums = iAlbums,
|
||||||
iTracks = iTracksD.await(),
|
iTracks = iTracksD.await(),
|
||||||
topSongs = topSongsD.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() {
|
fun closeArtistDetail() {
|
||||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||||
}
|
}
|
||||||
@@ -278,7 +320,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
val client = ImporterClient(backendUrl)
|
val client = ImporterClient(backendUrl)
|
||||||
runCatching { client.download(request) }
|
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}") }
|
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
import com.novasonic.app.api.model.ImportResolveResponse
|
import com.novasonic.app.api.model.ImportResolveResponse
|
||||||
|
import com.novasonic.app.api.model.ImportTrack
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.NotificationStore
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -26,11 +27,15 @@ data class ImporterUiState(
|
|||||||
val jobMessage: String? = null,
|
val jobMessage: String? = null,
|
||||||
val jobCompleted: Int = 0,
|
val jobCompleted: Int = 0,
|
||||||
val jobTotal: Int = 0,
|
val jobTotal: Int = 0,
|
||||||
|
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||||
|
val previewingId: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ImporterUiState())
|
private val _state = MutableStateFlow(ImporterUiState())
|
||||||
val state: StateFlow<ImporterUiState> = _state
|
val state: StateFlow<ImporterUiState> = _state
|
||||||
@@ -41,8 +46,29 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
_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 onLink(v: String) {
|
fun onLink(v: String) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
link = v,
|
link = v,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.Album
|
import com.novasonic.app.api.model.Album
|
||||||
@@ -34,6 +36,12 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||||
val state: StateFlow<LibraryUiState> = _state
|
val state: StateFlow<LibraryUiState> = _state
|
||||||
|
|
||||||
|
// 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() }
|
init { loadPlaylists() }
|
||||||
|
|
||||||
fun setAlbumSort(sort: AlbumSort) {
|
fun setAlbumSort(sort: AlbumSort) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
import com.novasonic.app.api.model.SearchResult3
|
import com.novasonic.app.api.model.SearchResult3
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -16,6 +18,8 @@ data class SearchUiState(
|
|||||||
val searchResult: SearchResult3? = null,
|
val searchResult: SearchResult3? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
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) {
|
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -25,8 +29,18 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(SearchUiState())
|
private val _state = MutableStateFlow(SearchUiState())
|
||||||
val state: StateFlow<SearchUiState> = _state
|
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
|
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 onQueryChange(q: String) = onSearch(q)
|
||||||
|
|
||||||
fun onSearch(q: String) {
|
fun onSearch(q: String) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.AbsClient
|
import com.novasonic.app.api.AbsClient
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.PrefsStore
|
import com.novasonic.app.data.PrefsStore
|
||||||
|
import com.novasonic.app.ui.theme.NsColors
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.android.Android
|
import io.ktor.client.engine.android.Android
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
@@ -28,6 +29,7 @@ data class SettingsUiState(
|
|||||||
val password: String = "",
|
val password: String = "",
|
||||||
val lastFmKey: String = "",
|
val lastFmKey: String = "",
|
||||||
val backendUrl: String = "",
|
val backendUrl: String = "",
|
||||||
|
val scrobbleUrl: String = "",
|
||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
@@ -39,13 +41,15 @@ data class SettingsUiState(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||||
val backendStatus: ConnStatus = ConnStatus.Idle,
|
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||||
|
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
|
||||||
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
|
val state: StateFlow<SettingsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -57,6 +61,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
password = s.password,
|
password = s.password,
|
||||||
lastFmKey = s.lastFmKey,
|
lastFmKey = s.lastFmKey,
|
||||||
backendUrl = s.backendUrl,
|
backendUrl = s.backendUrl,
|
||||||
|
scrobbleUrl = s.scrobbleUrl,
|
||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
@@ -73,6 +78,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = 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 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 onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = 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 onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||||
@@ -124,6 +130,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() {
|
fun save() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||||
@@ -139,6 +170,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
password = s.password,
|
password = s.password,
|
||||||
lastFmKey = s.lastFmKey.trim(),
|
lastFmKey = s.lastFmKey.trim(),
|
||||||
backendUrl = s.backendUrl.trimEnd('/'),
|
backendUrl = s.backendUrl.trimEnd('/'),
|
||||||
|
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
|
||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
|
|||||||
@@ -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,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>
|
||||||
+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 strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
|
||||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||||
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
||||||
const titleOnly = results.find(t => normalizeTitle(t.title) === normT);
|
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
|
||||||
return strict || (!isJp ? titleOnly || null : null);
|
// 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.
|
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
|
||||||
|
|||||||
+19
-5
@@ -51,13 +51,20 @@ const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
|||||||
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
// 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;
|
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
|
// Robust duplicate detection: search case-insensitively across all album folders
|
||||||
// under the artist folder matching firstArtist, compare normalized title ignoring
|
// under the artist folder matching firstArtist, compare normalized title ignoring
|
||||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||||
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
||||||
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||||
const norm = s => (s || '').toLowerCase()
|
const norm = s => (s || '').toLowerCase()
|
||||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
// 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 "_"
|
// 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".
|
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
|
||||||
.replace(/[/\\:*?"<>|_]/g, ' ')
|
.replace(/[/\\:*?"<>|_]/g, ' ')
|
||||||
@@ -83,11 +90,18 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
|||||||
if (!AUDIO_EXTS.includes(ext)) continue;
|
if (!AUDIO_EXTS.includes(ext)) continue;
|
||||||
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
||||||
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
|
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 di = stem.indexOf(' - ');
|
||||||
const rawTitle = di >= 0 ? stem.slice(di + 3) : stem;
|
const candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
|
||||||
const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, ''));
|
for (const cand of candidates) {
|
||||||
if (titleVariants.has(normStemTitle)) return path.join(albumPath, f);
|
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) return path.join(albumPath, f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 };
|
||||||
@@ -191,7 +191,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
||||||
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
||||||
const audioFile = hasAudio() ? expectedAudio : null;
|
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 });
|
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+190
-9
@@ -13,12 +13,14 @@ const { execSync, spawn } = require('child_process');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const CONFIG = require('./lib/config');
|
const CONFIG = require('./lib/config');
|
||||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||||
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
||||||
const dz = require('./lib/deezer');
|
const dz = require('./lib/deezer');
|
||||||
|
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
|
||||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||||
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
||||||
|
|
||||||
@@ -232,6 +234,47 @@ app.delete('/api/romaji-overrides/:kanji', (req, res) => {
|
|||||||
res.json({ ok: true });
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
// POST /api/romaji-convert — convert an array of strings to romaji
|
||||||
app.post('/api/romaji-convert', (req, res) => {
|
app.post('/api/romaji-convert', (req, res) => {
|
||||||
const { texts } = req.body;
|
const { texts } = req.body;
|
||||||
@@ -289,6 +332,8 @@ app.get('/api/meta/lookup', async (req, res) => {
|
|||||||
const { artist, title } = req.query;
|
const { artist, title } = req.query;
|
||||||
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||||
try {
|
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);
|
const meta = await dz.lookupTrackMeta(artist, title);
|
||||||
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
|
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
|
||||||
res.json({
|
res.json({
|
||||||
@@ -326,10 +371,38 @@ app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
|
|||||||
}).on('error', e => res.status(502).json({ error: e.message }));
|
}).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) => {
|
app.get('/api/events/:artist', (req, res) => {
|
||||||
const key = CONFIG.ticketmasterKey;
|
const key = CONFIG.ticketmasterKey;
|
||||||
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
|
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
|
||||||
const artist = req.params.artist;
|
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 now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); // 2024-06-08T12:00:00Z
|
||||||
const url = `https://app.ticketmaster.com/discovery/v2/events.json` +
|
const url = `https://app.ticketmaster.com/discovery/v2/events.json` +
|
||||||
`?keyword=${encodeURIComponent(artist)}&classificationName=music` +
|
`?keyword=${encodeURIComponent(artist)}&classificationName=music` +
|
||||||
@@ -343,15 +416,18 @@ app.get('/api/events/:artist', (req, res) => {
|
|||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
if (upstream.statusCode !== 200 || data.fault || data.errors) {
|
if (upstream.statusCode !== 200 || data.fault || data.errors) {
|
||||||
console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200));
|
console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200));
|
||||||
return res.json([]);
|
return respondWithCacheFallback([]);
|
||||||
}
|
}
|
||||||
const raw = data?._embedded?.events || [];
|
const raw = data?._embedded?.events || [];
|
||||||
const events = raw.map(e => {
|
const events = raw.map(e => {
|
||||||
const venue = e._embedded?.venues?.[0] || {};
|
const venue = e._embedded?.venues?.[0] || {};
|
||||||
const start = e.dates?.start || {};
|
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'}`
|
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||||
: null);
|
: start.dateTime) || null;
|
||||||
const soldOut = e.dates?.status?.code === 'offsale' ||
|
const soldOut = e.dates?.status?.code === 'offsale' ||
|
||||||
e.dates?.status?.code === 'cancelled';
|
e.dates?.status?.code === 'cancelled';
|
||||||
return {
|
return {
|
||||||
@@ -364,11 +440,11 @@ app.get('/api/events/:artist', (req, res) => {
|
|||||||
offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [],
|
offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [],
|
||||||
};
|
};
|
||||||
}).filter(e => e.datetime);
|
}).filter(e => e.datetime);
|
||||||
console.log(`[events] ${artist}: ${events.length} events`);
|
console.log(`[events] ${artist}: ${events.length} events (live)`);
|
||||||
res.json(events);
|
respondWithCacheFallback(events);
|
||||||
} catch(err) { console.error('[events] parse error:', err); res.json([]); }
|
} 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.
|
// Debug-only: returns raw Ticketmaster response so you can see exactly what the API sends back.
|
||||||
@@ -391,6 +467,109 @@ app.get('/api/events-debug/:artist', (req, res) => {
|
|||||||
}).on('error', e => res.json({ error: e.message }));
|
}).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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 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) => {
|
||||||
|
if (err) return res.status(404).json({ error: '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(new URL(up.headers.location, u).toString(), res, redirectsLeft - 1);
|
||||||
|
}
|
||||||
|
if (up.statusCode !== 200) { up.resume(); 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', () => _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) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok', ytdlpVersion,
|
status: 'ok', ytdlpVersion,
|
||||||
@@ -524,7 +703,7 @@ app.post('/api/youtube/resolve', async (req, res) => {
|
|||||||
const title = e.title || '';
|
const title = e.title || '';
|
||||||
const sourceUrl = e.webpage_url || url;
|
const sourceUrl = e.webpage_url || url;
|
||||||
let meta = {};
|
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 artist = meta.artist || rawArtist;
|
||||||
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
|
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
|
||||||
@@ -583,7 +762,7 @@ app.post('/api/download', async (req, res) => {
|
|||||||
const searchArtist = artist.split(',')[0].trim();
|
const searchArtist = artist.split(',')[0].trim();
|
||||||
if (!meta.album) {
|
if (!meta.album) {
|
||||||
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
|
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 || '?'})`);
|
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
|
||||||
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
|
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
|
||||||
} else {
|
} else {
|
||||||
@@ -610,6 +789,8 @@ app.post('/api/download', async (req, res) => {
|
|||||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||||
} else if (result?.notFound) {
|
} else if (result?.notFound) {
|
||||||
updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' });
|
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 {
|
} else {
|
||||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
|
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,21 @@ test('pickTrackMatch: strict match on artist + title', () => {
|
|||||||
assert.equal(m, results[1]);
|
assert.equal(m, results[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pickTrackMatch: title-only fallback for non-Japanese when artist differs', () => {
|
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
|
||||||
const results = [{ title: 'Levitating', artist: { name: 'Some Cover Act' } }];
|
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
|
||||||
const m = dz.pickTrackMatch(results, 'Dua Lipa', 'Levitating');
|
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
|
||||||
assert.equal(m, results[0]); // ASCII title → title-only fallback allowed
|
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', () => {
|
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
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
|
||||||
|
});
|
||||||
@@ -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), {});
|
||||||
|
});
|
||||||
+35
-5
@@ -10,10 +10,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
||||||
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
|
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
|
||||||
|
- SCROBBLE_URL=${SCROBBLE_URL} # play-log / stats service (setup screen)
|
||||||
|
|
||||||
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||||
novasonic-backend:
|
novasonic-backend:
|
||||||
image: node:20-slim
|
image: node:22-slim
|
||||||
container_name: novasonic-backend
|
container_name: novasonic-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
@@ -32,14 +33,43 @@ services:
|
|||||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||||
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
||||||
|
# Android APK download button — backend proxies a fixed Gitea release ("apk-latest"), same LAN.
|
||||||
|
# Optional: leave unset to use the local /data/novasonic.apk fallback instead.
|
||||||
|
- GITEA_URL=${GITEA_URL:-} # internal Gitea base, e.g. http://gitea:3000
|
||||||
|
- GITEA_REPO=${GITEA_REPO:-} # owner/repo, e.g. Docker/NovaSonic
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN:-} # read token (only for a private repo)
|
||||||
|
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git && rm -rf /var/lib/apt/lists/* &&
|
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && 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 -U --pre 'yt-dlp[default]' &&
|
||||||
|
curl -fsSL https://deno.land/install.sh | sh &&
|
||||||
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
|
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
|
||||||
mkdir -p /deps && cd /deps && npm install express kuromoji &&
|
mkdir -p /deps && cd /deps && npm install express kuromoji &&
|
||||||
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
|
cd /app && PATH=/root/.deno/bin:$$PATH NODE_PATH=/deps/node_modules node novasonic-server.js
|
||||||
|
"
|
||||||
|
|
||||||
|
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
|
||||||
|
novasonic-scrobble:
|
||||||
|
image: node:22-slim
|
||||||
|
container_name: novasonic-scrobble
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./scrobble:/app:ro # server code (read-only)
|
||||||
|
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
|
||||||
|
ports:
|
||||||
|
- "3710:3710" # route this port through your reverse proxy
|
||||||
|
environment:
|
||||||
|
- PORT=3710
|
||||||
|
- DATA_PATH=/data
|
||||||
|
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
mkdir -p /deps && cd /deps && npm install express &&
|
||||||
|
cd /app && NODE_PATH=/deps/node_modules node --experimental-sqlite scrobble-server.js
|
||||||
"
|
"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
novasonic-data: # persistent storage for romaji overrides and app state
|
novasonic-data: # persistent storage for romaji overrides and app state
|
||||||
|
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Generate runtime config from env vars so the frontend knows the Navidrome URL
|
# Generate runtime config from env vars so the frontend knows the Navidrome URL
|
||||||
cat > /usr/share/nginx/html/config.js <<EOF
|
cat > /usr/share/nginx/html/config.js <<EOF
|
||||||
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
|
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
|
||||||
window.NS_BACKEND_URL = "${BACKEND_URL:-}";
|
window.NS_BACKEND_URL = "${BACKEND_URL:-}";
|
||||||
|
window.NS_SCROBBLE_URL = "${SCROBBLE_URL:-}";
|
||||||
EOF
|
EOF
|
||||||
exec nginx -g "daemon off;"
|
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.
|
||||||
|
```
|
||||||
+91
-89
@@ -85,9 +85,6 @@
|
|||||||
<div class="nav-item" onclick="navigate('random')" data-page="random">
|
<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
|
<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>
|
||||||
<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>
|
||||||
<div class="nav-section" id="nav-abs-section" style="display:none">
|
<div class="nav-section" id="nav-abs-section" style="display:none">
|
||||||
<div class="nav-section-title">Hörbücher</div>
|
<div class="nav-section-title">Hörbücher</div>
|
||||||
@@ -121,6 +118,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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-overlay" style="display:none">
|
||||||
<div id="header-search-results"></div>
|
<div id="header-search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,22 +165,22 @@
|
|||||||
<div id="artist-songs-list"></div>
|
<div id="artist-songs-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ITUNES ALBUM DETAIL -->
|
<!-- DEEZER ALBUM DETAIL -->
|
||||||
<div class="page" id="page-itunes-album-detail">
|
<div class="page" id="page-deezer-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>
|
<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-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-meta">
|
||||||
<div class="album-type">Deezer</div>
|
<div class="album-type">Deezer</div>
|
||||||
<div class="album-name" id="itunes-album-detail-name">—</div>
|
<div class="album-name" id="deezer-album-detail-name">—</div>
|
||||||
<div class="album-artist-name" id="itunes-album-detail-artist"></div>
|
<div class="album-artist-name" id="deezer-album-detail-artist"></div>
|
||||||
<div class="album-stats" id="itunes-album-detail-stats"></div>
|
<div class="album-stats" id="deezer-album-detail-stats"></div>
|
||||||
<div class="album-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div id="itunes-album-songs"></div>
|
<div id="deezer-album-songs"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
|
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
|
||||||
@@ -329,20 +328,76 @@
|
|||||||
<div id="search-results"></div>
|
<div id="search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DISCOVER (Global) -->
|
<!-- DISCOVER (Global) — Deezer search + link importer unified in ONE field -->
|
||||||
<div class="page" id="page-discover">
|
<div class="page" id="page-discover">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-title"><span class="blue">Global</span> Suche</div>
|
<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>
|
||||||
<div class="search-wrap" style="padding:0 0 20px">
|
<div class="search-wrap" style="padding:0 0 12px">
|
||||||
<div class="search-box" style="background:var(--surface);border-color:var(--border2)">
|
<div style="display:flex;gap:8px">
|
||||||
<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>
|
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
|
||||||
<input id="global-search-input" type="text" placeholder="Künstler, Titel, Album suchen…" oninput="onGlobalSearch(this.value)" style="font-size:15px">
|
<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>
|
||||||
|
<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>
|
||||||
</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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,7 +405,7 @@
|
|||||||
<div class="page" id="page-new-releases">
|
<div class="page" id="page-new-releases">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-title">Neue <em>Releases</em></div>
|
<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>
|
||||||
<div class="section-header" style="margin-top:0"><span class="section-title">Top Alben weltweit</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>
|
<div id="nr-albums" class="grid grid-4"></div>
|
||||||
@@ -390,69 +445,6 @@
|
|||||||
<div id="abs-book-detail-content"></div>
|
<div id="abs-book-detail-content"></div>
|
||||||
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,6 +605,18 @@
|
|||||||
<div id="s-cover-color-bg" class="toggle" onclick="this.classList.toggle('on')"></div>
|
<div id="s-cover-color-bg" class="toggle" onclick="this.classList.toggle('on')"></div>
|
||||||
</div>
|
</div>
|
||||||
</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 class="settings-section">
|
<div class="settings-section">
|
||||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
<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>
|
<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>
|
||||||
@@ -712,6 +716,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: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>
|
<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>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1003,8 +1009,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-edit-fields">
|
<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">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">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> 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">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">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>
|
<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">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
@@ -1190,10 +1196,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>
|
<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
|
Zufällig
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"icons": "node scripts/gen-icons.mjs"
|
"icons": "node scripts/gen-icons.mjs",
|
||||||
|
"publish-apk": "node scripts/publish-apk.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'ns-v56';
|
const CACHE = 'ns-v78';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
// 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
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build the NovaSonic Android APK and upload it to the fixed Gitea release ("apk-latest"), replacing
|
||||||
|
// the previous .apk asset — so the web "Android App" download button always serves the latest build.
|
||||||
|
//
|
||||||
|
// One command after your changes: npm run publish-apk
|
||||||
|
//
|
||||||
|
// Config (env, or a gitignored scripts/.env.release file with KEY=VALUE lines):
|
||||||
|
// GITEA_URL internal/LAN Gitea base reachable from THIS machine, e.g. http://192.168.1.10:3000
|
||||||
|
// GITEA_REPO owner/repo, e.g. Docker/NovaSonic
|
||||||
|
// GITEA_TOKEN Gitea token with write access to the repo (repo/releases)
|
||||||
|
// GITEA_APK_TAG fixed release tag (default: apk-latest)
|
||||||
|
// APK_GRADLE_TASK gradle task (default: assembleDebug — installable; release is unsigned here)
|
||||||
|
// APK_FILE override the built APK path if you use a custom task
|
||||||
|
//
|
||||||
|
// The debug build is used by default because the release build type has no signingConfig yet, so a
|
||||||
|
// release APK would be unsigned and refuse to install. Same dev machine ⇒ stable debug key ⇒ updates
|
||||||
|
// install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
// ── tiny .env.release loader (never committed) ───────────────────────────────
|
||||||
|
const envFile = join(root, 'scripts', '.env.release');
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
for (const line of readFileSync(envFile, 'utf8').split('\n')) {
|
||||||
|
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
|
||||||
|
const GITEA_REPO = process.env.GITEA_REPO || '';
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||||
|
const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
|
||||||
|
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
|
||||||
|
|
||||||
|
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
|
||||||
|
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
|
||||||
|
die('GITEA_URL, GITEA_REPO und GITEA_TOKEN müssen gesetzt sein (env oder scripts/.env.release).');
|
||||||
|
|
||||||
|
const API = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`;
|
||||||
|
const authHeaders = { Authorization: `token ${GITEA_TOKEN}` };
|
||||||
|
|
||||||
|
// ── bump versionCode + read versionName from the Gradle config ───────────────
|
||||||
|
// The in-app updater compares versionCode, so every published build MUST increment it — we do that
|
||||||
|
// here automatically (pass --version X.Y to also set the versionName). Commit the changed
|
||||||
|
// build.gradle.kts afterwards so the repo reflects what's live.
|
||||||
|
const gradlePath = join(root, 'android', 'app', 'build.gradle.kts');
|
||||||
|
let gradle = readFileSync(gradlePath, 'utf8');
|
||||||
|
const curCode = parseInt(gradle.match(/versionCode\s*=\s*(\d+)/)?.[1] || '0', 10);
|
||||||
|
const versionCode = curCode + 1;
|
||||||
|
const versionArg = process.argv.slice(2).find(a => a.startsWith('--version='))?.split('=')[1]
|
||||||
|
|| (process.argv.includes('--version') ? process.argv[process.argv.indexOf('--version') + 1] : null);
|
||||||
|
let version = versionArg || gradle.match(/versionName\s*=\s*"([^"]+)"/)?.[1] || `0.${versionCode}`;
|
||||||
|
gradle = gradle
|
||||||
|
.replace(/versionCode\s*=\s*\d+/, `versionCode = ${versionCode}`)
|
||||||
|
.replace(/versionName\s*=\s*"[^"]*"/, `versionName = "${version}"`);
|
||||||
|
writeFileSync(gradlePath, gradle);
|
||||||
|
console.log(`▶ versionCode → ${versionCode}, versionName → ${version} (build.gradle.kts danach committen!)`);
|
||||||
|
|
||||||
|
const relBody = `versionCode=${versionCode}\nversionName=${version}`;
|
||||||
|
|
||||||
|
// ── 1) build ─────────────────────────────────────────────────────────────────
|
||||||
|
const isWin = process.platform === 'win32';
|
||||||
|
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
|
||||||
|
console.log(`▶ Baue APK: ${TASK} …`);
|
||||||
|
const build = spawnSync(gradlew, [`:app:${TASK}`], {
|
||||||
|
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
|
||||||
|
});
|
||||||
|
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
|
||||||
|
|
||||||
|
// derive the APK path from the task (assembleDebug → debug/app-debug.apk)
|
||||||
|
const variant = TASK.replace(/^assemble/, '').toLowerCase() || 'debug';
|
||||||
|
const apkPath = process.env.APK_FILE
|
||||||
|
|| join(root, 'android', 'app', 'build', 'outputs', 'apk', variant, `app-${variant}.apk`);
|
||||||
|
if (!existsSync(apkPath)) die(`APK nicht gefunden: ${apkPath} (APK_FILE setzen, falls Task abweicht).`);
|
||||||
|
const apkBytes = readFileSync(apkPath);
|
||||||
|
console.log(`✓ Gebaut: ${apkPath} (${(apkBytes.length / 1048576).toFixed(1)} MB)`);
|
||||||
|
|
||||||
|
// ── 2) get-or-create the fixed release ───────────────────────────────────────
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const r = await fetch(`${API}${path}`, { ...opts, headers: { ...authHeaders, ...(opts.headers || {}) } });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel;
|
||||||
|
{
|
||||||
|
const r = await api(`/releases/tags/${encodeURIComponent(TAG)}`);
|
||||||
|
if (r.status === 200) {
|
||||||
|
rel = await r.json();
|
||||||
|
} else if (r.status === 404) {
|
||||||
|
console.log(`▶ Release "${TAG}" existiert nicht — lege sie an …`);
|
||||||
|
const cr = await api('/releases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tag_name: TAG, target: 'main', name: `NovaSonic ${version}`, body: relBody }),
|
||||||
|
});
|
||||||
|
if (!cr.ok) die(`Release anlegen fehlgeschlagen: ${cr.status} ${await cr.text()}`);
|
||||||
|
rel = await cr.json();
|
||||||
|
} else {
|
||||||
|
die(`Release-Abfrage fehlgeschlagen: ${r.status} ${await r.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3) delete old .apk assets, upload the new one ────────────────────────────
|
||||||
|
for (const a of rel.assets || []) {
|
||||||
|
if (/\.apk$/i.test(a.name)) {
|
||||||
|
const d = await api(`/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
|
||||||
|
console.log(d.ok ? `✓ Altes Asset gelöscht: ${a.name}` : `⚠ Löschen von ${a.name} fehlgeschlagen (${d.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('attachment', new Blob([apkBytes], { type: 'application/vnd.android.package-archive' }), 'NovaSonic.apk');
|
||||||
|
const up = await api(`/releases/${rel.id}/assets?name=NovaSonic.apk`, { method: 'POST', body: form });
|
||||||
|
if (!up.ok) die(`Upload fehlgeschlagen: ${up.status} ${await up.text()}`);
|
||||||
|
|
||||||
|
// keep the release title + versionCode body in sync
|
||||||
|
await api(`/releases/${rel.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: `NovaSonic ${version}`, body: relBody }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "novasonic-scrobble",
|
||||||
|
"private": true,
|
||||||
|
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
|
||||||
|
"engines": { "node": ">=22" },
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --experimental-sqlite scrobble-server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* NovaSonic Scrobble Service
|
||||||
|
* ---------------------------
|
||||||
|
* A tiny, isolated service that records a timestamped, per-user play-log — the
|
||||||
|
* foundation for day/month/year listening stats and a yearly "Wrapped".
|
||||||
|
*
|
||||||
|
* Deliberately SEPARATE from the download backend: the download server is heavy
|
||||||
|
* (yt-dlp/ffmpeg subprocesses), may crash, and restarts regularly (to re-pull
|
||||||
|
* yt-dlp). This archive needs high uptime + data integrity, so it runs on its own.
|
||||||
|
*
|
||||||
|
* Storage: node:sqlite (built into Node 22; run with --experimental-sqlite).
|
||||||
|
* DB file: {DATA_PATH}/novasonic-plays.db (own persistent volume)
|
||||||
|
*
|
||||||
|
* Auth / multi-user: every request carries the same Subsonic token creds the PWA
|
||||||
|
* already uses (u, t=md5(pass+salt), s=salt). We verify them against Navidrome's
|
||||||
|
* ping.view once (cached briefly) → the `user` we store is real, not spoofable.
|
||||||
|
* No new login, no new password.
|
||||||
|
*
|
||||||
|
* Phase 1 routes:
|
||||||
|
* POST /scrobble/plays ingest a batch, idempotent by uuid
|
||||||
|
* GET /scrobble/history raw chronological log (= the archive)
|
||||||
|
* DELETE /scrobble/plays?user=NAME purge one user's rows (test cleanup)
|
||||||
|
* GET /scrobble/health liveness + total row count
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3710;
|
||||||
|
const DATA_PATH = process.env.DATA_PATH || '/data';
|
||||||
|
const NAVIDROME_URL = (process.env.NAVIDROME_URL || '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (!NAVIDROME_URL) console.warn('⚠ NAVIDROME_URL not set — auth verification will reject everything.');
|
||||||
|
|
||||||
|
// ─── DB ─────────────────────────────────────────────────────────────────────
|
||||||
|
fs.mkdirSync(DATA_PATH, { recursive: true });
|
||||||
|
const db = new DatabaseSync(path.join(DATA_PATH, 'novasonic-plays.db'));
|
||||||
|
db.exec('PRAGMA journal_mode = WAL;'); // survive restarts / concurrent reads
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS plays (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
song_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
artist_id TEXT,
|
||||||
|
album TEXT,
|
||||||
|
album_id TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
played_at INTEGER NOT NULL, -- Unix epoch, UTC
|
||||||
|
source TEXT, -- 'web' | 'android'
|
||||||
|
uuid TEXT UNIQUE, -- idempotency for offline-retry
|
||||||
|
cover_art TEXT, -- Subsonic coverArt token (NOT the entity id) for real covers
|
||||||
|
genre TEXT -- per-play genre (from the song's tags) for genre stats
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
// Migrations for DBs created before a column existed (ALTER throws if it's already there).
|
||||||
|
try { db.exec('ALTER TABLE plays ADD COLUMN cover_art TEXT'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE plays ADD COLUMN genre TEXT'); } catch {}
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_time ON plays(user, played_at);');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_artist ON plays(user, artist_id);');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);');
|
||||||
|
|
||||||
|
const _insert = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO plays
|
||||||
|
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art, genre)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const _history = db.prepare(`
|
||||||
|
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
|
||||||
|
FROM plays WHERE user = ? AND played_at < ? ORDER BY played_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
const _purge = db.prepare('DELETE FROM plays WHERE user = ?');
|
||||||
|
const _count = db.prepare('SELECT COUNT(*) AS n FROM plays');
|
||||||
|
|
||||||
|
// ─── Auth (Navidrome ping.view, cached) ─────────────────────────────────────
|
||||||
|
const _authCache = new Map(); // "u|t|s" -> { ok, exp }
|
||||||
|
const _AUTH_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
async function verifyUser(req) {
|
||||||
|
const u = req.query.u || req.body?.u;
|
||||||
|
const t = req.query.t || req.body?.t;
|
||||||
|
const s = req.query.s || req.body?.s;
|
||||||
|
if (!u || !t || !s || !NAVIDROME_URL) return null;
|
||||||
|
|
||||||
|
const key = `${u}|${t}|${s}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = _authCache.get(key);
|
||||||
|
if (cached && cached.exp > now) return cached.ok ? u : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
|
||||||
|
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const j = await r.json();
|
||||||
|
const ok = j?.['subsonic-response']?.status === 'ok';
|
||||||
|
_authCache.set(key, { ok, exp: now + _AUTH_TTL });
|
||||||
|
return ok ? u : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App ────────────────────────────────────────────────────────────────────
|
||||||
|
const app = express();
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||||
|
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
|
// Normalize a client timestamp to Unix seconds (accepts s or ms; defaults to now)
|
||||||
|
function _toEpochSec(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return Math.floor(Date.now() / 1000);
|
||||||
|
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /scrobble/plays — ingest a batch { plays: [ {...} ] }, idempotent by uuid
|
||||||
|
app.post('/scrobble/plays', async (req, res) => {
|
||||||
|
const user = await verifyUser(req);
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
|
||||||
|
const plays = Array.isArray(req.body?.plays) ? req.body.plays : [];
|
||||||
|
if (!plays.length) return res.json({ accepted: 0, duplicates: 0 });
|
||||||
|
|
||||||
|
let accepted = 0, duplicates = 0, invalid = 0;
|
||||||
|
for (const p of plays) {
|
||||||
|
if (!p || !p.songId) { invalid++; continue; }
|
||||||
|
const uuid = String(p.uuid || crypto.randomUUID());
|
||||||
|
try {
|
||||||
|
const r = _insert.run(
|
||||||
|
user,
|
||||||
|
String(p.songId),
|
||||||
|
p.title ?? null,
|
||||||
|
p.artist ?? null,
|
||||||
|
p.artistId ?? null,
|
||||||
|
p.album ?? null,
|
||||||
|
p.albumId ?? null,
|
||||||
|
Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null,
|
||||||
|
_toEpochSec(p.playedAt),
|
||||||
|
p.source === 'android' ? 'android' : 'web',
|
||||||
|
uuid,
|
||||||
|
p.coverArt ?? null,
|
||||||
|
p.genre ?? null
|
||||||
|
);
|
||||||
|
if (r.changes > 0) accepted++; else duplicates++;
|
||||||
|
} catch { invalid++; }
|
||||||
|
}
|
||||||
|
res.json({ accepted, duplicates, invalid });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scrobble/history?limit=&before= — raw chronological log (the archive)
|
||||||
|
app.get('/scrobble/history', async (req, res) => {
|
||||||
|
const user = await verifyUser(req);
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||||
|
const before = _toEpochSec(req.query.before || Date.now());
|
||||||
|
const rows = _history.all(user, before, limit);
|
||||||
|
res.json({ user, count: rows.length, plays: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scrobble/stats — aggregated listening stats for the authed user.
|
||||||
|
// ?from=&to= Unix seconds window (default: all time)
|
||||||
|
// ?tz= seconds east of UTC for local-day bucketing (default 0)
|
||||||
|
// ?bucket= day | month | year — granularity of the time series (default day)
|
||||||
|
// ?limit= top-list size (default 20, max 100)
|
||||||
|
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
|
||||||
|
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
|
||||||
|
|
||||||
|
app.get('/scrobble/stats', async (req, res) => {
|
||||||
|
const user = await verifyUser(req);
|
||||||
|
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
|
||||||
|
const tz = Math.trunc(Number(req.query.tz) || 0);
|
||||||
|
// Default window = all time. Parse `from` directly — do NOT route through _toEpochSec, which maps
|
||||||
|
// 0 → now (so the client's explicit from=0 for "all time" would wrongly become an empty window).
|
||||||
|
const fromN = Number(req.query.from);
|
||||||
|
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
|
||||||
|
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
|
||||||
|
const bucket = _STATS_FMT[req.query.bucket] ? req.query.bucket : 'day';
|
||||||
|
const fmt = _STATS_FMT[bucket];
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
|
||||||
|
|
||||||
|
const win = { user, from, to }; // node:sqlite throws on extra named params —
|
||||||
|
const winL = { user, from, to, limit }; // so each query gets exactly the params it uses.
|
||||||
|
const winT = { user, from, to, tz };
|
||||||
|
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
||||||
|
|
||||||
|
const totals = db.prepare(
|
||||||
|
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
|
||||||
|
).get(win);
|
||||||
|
|
||||||
|
// Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the
|
||||||
|
// navigator so you can't step before logging began.
|
||||||
|
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
|
||||||
|
|
||||||
|
const topArtists = db.prepare(`
|
||||||
|
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||||
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||||
|
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
|
||||||
|
`).all(winL);
|
||||||
|
|
||||||
|
const topAlbums = db.prepare(`
|
||||||
|
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
|
||||||
|
MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
|
||||||
|
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
|
||||||
|
`).all(winL);
|
||||||
|
|
||||||
|
const topTracks = db.prepare(`
|
||||||
|
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
|
||||||
|
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
|
||||||
|
song_id AS songId, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE}
|
||||||
|
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
|
||||||
|
`).all(winL);
|
||||||
|
|
||||||
|
const topGenres = db.prepare(`
|
||||||
|
SELECT MAX(genre) AS name, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
|
||||||
|
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit
|
||||||
|
`).all(winL);
|
||||||
|
|
||||||
|
const series = db.prepare(`
|
||||||
|
SELECT strftime('${fmt}', played_at + :tz, 'unixepoch') AS bucket, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||||
|
FROM plays WHERE ${WHERE} GROUP BY bucket ORDER BY bucket
|
||||||
|
`).all(winT);
|
||||||
|
|
||||||
|
const hourRows = db.prepare(`
|
||||||
|
SELECT CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) AS hr, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} GROUP BY hr
|
||||||
|
`).all(winT);
|
||||||
|
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, plays: 0 }));
|
||||||
|
for (const r of hourRows) if (r.hr >= 0 && r.hr < 24) byHour[r.hr].plays = r.plays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user,
|
||||||
|
range: { from, to, tz, bucket },
|
||||||
|
firstPlayedAt,
|
||||||
|
totalPlays: totals.plays,
|
||||||
|
totalSeconds: totals.seconds,
|
||||||
|
topArtists, topAlbums, topTracks, topGenres, series, byHour,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scrobble/users — distinct users in the log, for the Head-to-Head partner picker.
|
||||||
|
// Any valid Navidrome login may list them (so you can pick who to compare against).
|
||||||
|
app.get('/scrobble/users', async (req, res) => {
|
||||||
|
const me = await verifyUser(req);
|
||||||
|
if (!me) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT user, COUNT(*) AS plays, MAX(played_at) AS lastPlayedAt
|
||||||
|
FROM plays GROUP BY user ORDER BY plays DESC
|
||||||
|
`).all();
|
||||||
|
res.json({ me, users: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
|
||||||
|
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
|
||||||
|
// Also returns shared artists/tracks and each side's unique top artists.
|
||||||
|
app.get('/scrobble/compare', async (req, res) => {
|
||||||
|
const userA = await verifyUser(req);
|
||||||
|
if (!userA) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
const userB = String(req.query.with || '').trim();
|
||||||
|
if (!userB) return res.status(400).json({ error: 'missing ?with=' });
|
||||||
|
if (userB === userA) return res.status(400).json({ error: 'same user' });
|
||||||
|
|
||||||
|
// Window (default all-time). Parse `from` directly — _toEpochSec maps 0 → now (see /stats).
|
||||||
|
const fromN = Number(req.query.from);
|
||||||
|
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
|
||||||
|
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
|
||||||
|
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
||||||
|
|
||||||
|
const artistsOf = u => db.prepare(`
|
||||||
|
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||||
|
GROUP BY lower(artist)
|
||||||
|
`).all({ user: u, from, to });
|
||||||
|
const tracksOf = u => db.prepare(`
|
||||||
|
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
||||||
|
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||||
|
FROM plays WHERE ${WHERE} GROUP BY song_id
|
||||||
|
`).all({ user: u, from, to });
|
||||||
|
const totalsOf = u => db.prepare(
|
||||||
|
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
|
||||||
|
).get({ user: u, from, to });
|
||||||
|
|
||||||
|
const aArtists = artistsOf(userA), bArtists = artistsOf(userB);
|
||||||
|
const aMap = new Map(aArtists.map(r => [r.k, r]));
|
||||||
|
const bMap = new Map(bArtists.map(r => [r.k, r]));
|
||||||
|
|
||||||
|
// Cosine similarity over the artist-play vectors → a stable 0..100 "match".
|
||||||
|
let dot = 0, nA = 0, nB = 0;
|
||||||
|
for (const r of aArtists) nA += r.plays * r.plays;
|
||||||
|
for (const r of bArtists) nB += r.plays * r.plays;
|
||||||
|
for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) dot += ra.plays * rb.plays; }
|
||||||
|
const matchScore = (nA && nB) ? Math.round((dot / (Math.sqrt(nA) * Math.sqrt(nB))) * 100) : 0;
|
||||||
|
|
||||||
|
const sharedArtists = [];
|
||||||
|
for (const [k, ra] of aMap) {
|
||||||
|
const rb = bMap.get(k);
|
||||||
|
if (rb) sharedArtists.push({ name: ra.name, artistId: ra.artistId || rb.artistId, playsA: ra.plays, playsB: rb.plays });
|
||||||
|
}
|
||||||
|
sharedArtists.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
|
||||||
|
|
||||||
|
const onlyTop = (mine, other) => mine
|
||||||
|
.filter(r => !other.has(r.k))
|
||||||
|
.sort((x, y) => y.plays - x.plays).slice(0, 8)
|
||||||
|
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
|
||||||
|
|
||||||
|
const aTracks = tracksOf(userA);
|
||||||
|
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
|
||||||
|
const sharedTracks = [];
|
||||||
|
for (const ta of aTracks) {
|
||||||
|
const tb = bTrackMap.get(ta.songId);
|
||||||
|
if (tb) sharedTracks.push({
|
||||||
|
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
|
||||||
|
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
userA, userB, range: { from, to },
|
||||||
|
matchScore,
|
||||||
|
totalsA: { ...totalsOf(userA), artists: aArtists.length },
|
||||||
|
totalsB: { ...totalsOf(userB), artists: bArtists.length },
|
||||||
|
sharedArtists: sharedArtists.slice(0, 30),
|
||||||
|
sharedTracks: sharedTracks.slice(0, 30),
|
||||||
|
onlyA: onlyTop(aArtists, bMap),
|
||||||
|
onlyB: onlyTop(bArtists, aMap),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
|
||||||
|
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
|
||||||
|
// so cleanup after deletion must be authorized by a currently-valid account).
|
||||||
|
app.delete('/scrobble/plays', async (req, res) => {
|
||||||
|
const caller = await verifyUser(req);
|
||||||
|
if (!caller) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
const target = req.query.user || caller;
|
||||||
|
const r = _purge.run(String(target));
|
||||||
|
res.json({ purged: r.changes, user: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /scrobble/health — liveness + total rows (no auth)
|
||||||
|
app.get('/scrobble/health', (_req, res) => {
|
||||||
|
res.json({ ok: true, plays: _count.get().n, navidrome: !!NAVIDROME_URL });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`✅ NovaSonic scrobble service on :${PORT} (data: ${DATA_PATH})`));
|
||||||
+97
-14
@@ -7,6 +7,14 @@ export function apiUrl(method, extra='') {
|
|||||||
const token = md5(S.cfg.pass + salt);
|
const token = md5(S.cfg.pass + salt);
|
||||||
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
|
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
|
||||||
}
|
}
|
||||||
|
// Bare Subsonic token-auth params (u=..&t=..&s=..) for talking to non-Navidrome NovaSonic
|
||||||
|
// services (e.g. the scrobble play-log), which verify the same creds via ping.view.
|
||||||
|
export function subsonicAuthParams() {
|
||||||
|
if (!S.cfg.pass || !S.cfg.user) return '';
|
||||||
|
const salt = Math.random().toString(36).slice(2, 10);
|
||||||
|
const token = md5(S.cfg.pass + salt);
|
||||||
|
return `u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}`;
|
||||||
|
}
|
||||||
export async function api(method, extra='') {
|
export async function api(method, extra='') {
|
||||||
const res = await fetch(apiUrl(method, extra));
|
const res = await fetch(apiUrl(method, extra));
|
||||||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -186,10 +194,53 @@ export async function removeRomajiOverride(kanji) {
|
|||||||
} catch (e) { console.warn('removeRomajiOverride failed:', e); }
|
} catch (e) { console.warn('removeRomajiOverride failed:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ───
|
||||||
|
// Fixes same-name collisions on the artist page (fans / top songs / albums / image).
|
||||||
|
let _artistOverridesCache = null;
|
||||||
|
export function getArtistOverrides() { return _artistOverridesCache || {}; }
|
||||||
|
export function getArtistOverride(ndArtistId) { return (_artistOverridesCache || {})[String(ndArtistId)] || null; }
|
||||||
|
|
||||||
|
export async function loadArtistOverrides() {
|
||||||
|
if (!S.cfg?.backendUrl) { _artistOverridesCache = {}; return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${S.cfg.backendUrl}/api/artist-overrides`);
|
||||||
|
_artistOverridesCache = r.ok ? await r.json() : {};
|
||||||
|
} catch { _artistOverridesCache = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setArtistOverride(ndArtistId, deezer) {
|
||||||
|
if (!S.cfg?.backendUrl || !ndArtistId || !deezer?.deezerArtistId) return;
|
||||||
|
const entry = {
|
||||||
|
deezerArtistId: String(deezer.deezerArtistId),
|
||||||
|
name: deezer.name || '',
|
||||||
|
image: deezer.image || '',
|
||||||
|
fanCount: deezer.fanCount ?? null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetch(`${S.cfg.backendUrl}/api/artist-overrides`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ndArtistId: String(ndArtistId), ...entry }),
|
||||||
|
});
|
||||||
|
if (!_artistOverridesCache) _artistOverridesCache = {};
|
||||||
|
_artistOverridesCache[String(ndArtistId)] = entry;
|
||||||
|
} catch (e) { console.warn('setArtistOverride failed:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeArtistOverride(ndArtistId) {
|
||||||
|
if (!S.cfg?.backendUrl || !ndArtistId) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${S.cfg.backendUrl}/api/artist-overrides/${encodeURIComponent(ndArtistId)}`, { method: 'DELETE' });
|
||||||
|
if (_artistOverridesCache) delete _artistOverridesCache[String(ndArtistId)];
|
||||||
|
} catch (e) { console.warn('removeArtistOverride failed:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
export function dispArtistById(id, name) {
|
export function dispArtistById(id, name) {
|
||||||
if (!S.cfg.showRomaji) return name || '';
|
if (!S.cfg.showRomaji) return name || '';
|
||||||
const ov = getRomajiOverrides();
|
const ov = getRomajiOverrides();
|
||||||
if (name && ov[name]) return ov[name];
|
if (name && ov[name]) return ov[name];
|
||||||
|
// Only romanize Japanese names — for non-JP names the sort tag is a sort key (symbols/articles
|
||||||
|
// stripped, e.g. "☆NoAki☆" → "NoAki"), not a romaji, so it must not replace the display name.
|
||||||
|
if (!_isJp(name || '')) return name || '';
|
||||||
const sort = _artistSortMap?.get(String(id));
|
const sort = _artistSortMap?.get(String(id));
|
||||||
if (sort) return sort;
|
if (sort) return sort;
|
||||||
return _artistRomajiMap?.get(String(id)) || name || '';
|
return _artistRomajiMap?.get(String(id)) || name || '';
|
||||||
@@ -217,6 +268,7 @@ export function dispTitle(s) {
|
|||||||
if (!S.cfg.showRomaji) return s.title || '';
|
if (!S.cfg.showRomaji) return s.title || '';
|
||||||
const ov = getRomajiOverrides();
|
const ov = getRomajiOverrides();
|
||||||
if (s.title && ov[s.title]) return ov[s.title];
|
if (s.title && ov[s.title]) return ov[s.title];
|
||||||
|
if (!_isJp(s.title || '')) return s.title || ''; // non-JP title → sort tag is not a romaji
|
||||||
const st = s.sortName || s.titleSort;
|
const st = s.sortName || s.titleSort;
|
||||||
if (st && !_isJp(st)) return st;
|
if (st && !_isJp(st)) return st;
|
||||||
return romajiLookup(s.id)?.titleRomaji || s.title || '';
|
return romajiLookup(s.id)?.titleRomaji || s.title || '';
|
||||||
@@ -228,6 +280,7 @@ export function dispArtist(s) {
|
|||||||
const ov = getRomajiOverrides();
|
const ov = getRomajiOverrides();
|
||||||
if (s.artist && ov[s.artist]) return ov[s.artist];
|
if (s.artist && ov[s.artist]) return ov[s.artist];
|
||||||
if (s.albumArtist && ov[s.albumArtist]) return ov[s.albumArtist];
|
if (s.albumArtist && ov[s.albumArtist]) return ov[s.albumArtist];
|
||||||
|
if (!_isJp(s.artist || '')) return s.artist || ''; // non-JP artist → no romaji substitution
|
||||||
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
|
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
|
||||||
}
|
}
|
||||||
// Songs don't carry album sort — Navidrome only puts sortName on album objects.
|
// Songs don't carry album sort — Navidrome only puts sortName on album objects.
|
||||||
@@ -246,6 +299,7 @@ export function dispArtistParts(s, parseArtistsFn) {
|
|||||||
const ov = getRomajiOverrides();
|
const ov = getRomajiOverrides();
|
||||||
if (s.artist && ov[s.artist]) return [{ name: ov[s.artist], originalName: s.artist, id: parts[0]?.id || '' }];
|
if (s.artist && ov[s.artist]) return [{ name: ov[s.artist], originalName: s.artist, id: parts[0]?.id || '' }];
|
||||||
if (s.albumArtist && ov[s.albumArtist]) return [{ name: ov[s.albumArtist], originalName: s.albumArtist, id: parts[0]?.id || '' }];
|
if (s.albumArtist && ov[s.albumArtist]) return [{ name: ov[s.albumArtist], originalName: s.albumArtist, id: parts[0]?.id || '' }];
|
||||||
|
if (!_isJp(s.artist || parts[0]?.name || '')) return parts; // non-JP artist → keep original (e.g. ☆NoAki☆)
|
||||||
const st = s.sortArtist || s.artistSort;
|
const st = s.sortArtist || s.artistSort;
|
||||||
if (st && !_isJp(st)) return [{ name: st, originalName: s.artist || parts[0]?.name || '', id: parts[0]?.id || '' }];
|
if (st && !_isJp(st)) return [{ name: st, originalName: s.artist || parts[0]?.name || '', id: parts[0]?.id || '' }];
|
||||||
const info = romajiLookup(s.id);
|
const info = romajiLookup(s.id);
|
||||||
@@ -271,7 +325,7 @@ export async function romajiConvert(texts) {
|
|||||||
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for
|
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for
|
||||||
// everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns
|
// everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns
|
||||||
// explicit versions + artist images + track durations. Proxied through the backend to avoid any
|
// explicit versions + artist images + track durations. Proxied through the backend to avoid any
|
||||||
// CORS surprises. All results are mapped to the legacy "ItunesItem" shape so the UI + download
|
// CORS surprises. All results are mapped to the legacy "DeezerItem" shape so the UI + download
|
||||||
// flow stay unchanged.
|
// flow stay unchanged.
|
||||||
function _deezerBase() {
|
function _deezerBase() {
|
||||||
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
|
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
|
||||||
@@ -306,23 +360,23 @@ function _dzArtist(a) {
|
|||||||
artistId: a.id, artistName: a.name,
|
artistId: a.id, artistName: a.name,
|
||||||
artworkUrl100: a.picture_big || a.picture_medium || '',
|
artworkUrl100: a.picture_big || a.picture_medium || '',
|
||||||
// Deezer's artist search does NOT rank by popularity — several artists can share a name
|
// Deezer's artist search does NOT rank by popularity — several artists can share a name
|
||||||
// (5× "Joji"). fanCount lets us pick the one the user means (see itunesSearch + detail.js).
|
// (5× "Joji"). fanCount lets us pick the one the user means (see deezerSearch + detail.js).
|
||||||
fanCount: a.nb_fan ?? undefined,
|
fanCount: a.nb_fan ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last search outcome: null = ok, else HTTP status or 'network'. Read by the search view to tell
|
// Last search outcome: null = ok, else HTTP status or 'network'. Read by the search view to tell
|
||||||
// "no results" apart from "search backend down".
|
// "no results" apart from "search backend down".
|
||||||
let _itunesLastError = null;
|
let _deezerLastError = null;
|
||||||
export function itunesLastError() { return _itunesLastError; }
|
export function deezerLastError() { return _deezerLastError; }
|
||||||
|
|
||||||
// Keeps the old name so all callers stay unchanged. entity: musicTrack | album | musicArtist.
|
// Keeps the old name so all callers stay unchanged. entity: musicTrack | album | musicArtist.
|
||||||
export async function itunesSearch(query, entity='musicTrack', limit=25) {
|
export async function deezerSearch(query, entity='musicTrack', limit=25) {
|
||||||
try {
|
try {
|
||||||
const path = entity === 'album' ? 'search/album' : entity === 'musicArtist' ? 'search/artist' : 'search';
|
const path = entity === 'album' ? 'search/album' : entity === 'musicArtist' ? 'search/artist' : 'search';
|
||||||
const r = await fetch(`${_deezerBase()}/${path}?q=${encodeURIComponent(query)}&limit=${limit}`);
|
const r = await fetch(`${_deezerBase()}/${path}?q=${encodeURIComponent(query)}&limit=${limit}`);
|
||||||
if (!r.ok) { _itunesLastError = r.status; return []; }
|
if (!r.ok) { _deezerLastError = r.status; return []; }
|
||||||
_itunesLastError = null;
|
_deezerLastError = null;
|
||||||
const txt = await r.text();
|
const txt = await r.text();
|
||||||
const data = (txt ? JSON.parse(txt) : {}).data || [];
|
const data = (txt ? JSON.parse(txt) : {}).data || [];
|
||||||
if (entity === 'album') return data.map(_dzAlbum);
|
if (entity === 'album') return data.map(_dzAlbum);
|
||||||
@@ -330,12 +384,12 @@ export async function itunesSearch(query, entity='musicTrack', limit=25) {
|
|||||||
// (Deezer returns them in an unhelpful order — the real "Joji" isn't first).
|
// (Deezer returns them in an unhelpful order — the real "Joji" isn't first).
|
||||||
if (entity === 'musicArtist') return data.map(_dzArtist).sort((x, y) => (y.fanCount || 0) - (x.fanCount || 0));
|
if (entity === 'musicArtist') return data.map(_dzArtist).sort((x, y) => (y.fanCount || 0) - (x.fanCount || 0));
|
||||||
return data.map(_dzTrack);
|
return data.map(_dzTrack);
|
||||||
} catch { _itunesLastError = 'network'; return []; }
|
} catch { _deezerLastError = 'network'; return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album tracks (for the album detail view + album download). Deezer's /album/{id} embeds tracks +
|
// Album tracks (for the album detail view + album download). Deezer's /album/{id} embeds tracks +
|
||||||
// album-level release/genre/cover, so one request gives everything.
|
// album-level release/genre/cover, so one request gives everything.
|
||||||
export async function itunesLookupAlbum(collectionId) {
|
export async function deezerLookupAlbum(collectionId) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_deezerBase()}/album/${collectionId}`);
|
const r = await fetch(`${_deezerBase()}/album/${collectionId}`);
|
||||||
const a = await r.json();
|
const a = await r.json();
|
||||||
@@ -351,20 +405,49 @@ export async function itunesLookupAlbum(collectionId) {
|
|||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesLookupArtistSongs(artistId, limit=15) {
|
export async function deezerLookupArtistSongs(artistId, limit=15) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_deezerBase()}/artist/${artistId}/top?limit=${limit}`);
|
const r = await fetch(`${_deezerBase()}/artist/${artistId}/top?limit=${limit}`);
|
||||||
return ((await r.json()).data || []).map(_dzTrack);
|
return ((await r.json()).data || []).map(_dzTrack);
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesLookupArtistAlbums(artistId, limit=100) {
|
// Related Deezer artists (real pictures + fan counts). Used for the "Ähnliche Künstler" row when a
|
||||||
|
// pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome artist.
|
||||||
|
export async function deezerRelatedArtists(artistId, limit=12) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${_deezerBase()}/artist/${artistId}/related?limit=${limit}`);
|
||||||
|
return ((await r.json()).data || []).map(_dzArtist);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer's /artist/{id}/albums list OMITS the artist object, so _dzAlbum yields artistName:''.
|
||||||
|
// We already know whose page this is → backfill artistName so downstream download/detail cards
|
||||||
|
// don't fall back to the literal "Unknown Artist" (which poisoned the yt-dlp search).
|
||||||
|
export async function deezerLookupArtistAlbums(artistId, limit=100, artistName='') {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_deezerBase()}/artist/${artistId}/albums?limit=${limit}`);
|
const r = await fetch(`${_deezerBase()}/artist/${artistId}/albums?limit=${limit}`);
|
||||||
return ((await r.json()).data || []).map(_dzAlbum).filter(a => a.collectionType === 'Album');
|
return ((await r.json()).data || [])
|
||||||
|
.map(_dzAlbum)
|
||||||
|
.filter(a => a.collectionType === 'Album')
|
||||||
|
.map(a => (a.artistName ? a : { ...a, artistName }));
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct artist lookup by Deezer id — the reliable route when /search/artist doesn't surface a
|
||||||
|
// low-profile same-name artist (Deezer search is a ranked, incomplete fuzzy match; e.g. the browser
|
||||||
|
// finds Nana id 80239872 but the search API never returns it). Used by the artist-pin "paste link"
|
||||||
|
// path. Returns the full _dzArtist shape (or null on miss / bad id).
|
||||||
|
export async function deezerLookupArtist(artistId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${_deezerBase()}/artist/${artistId}`);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const a = await r.json();
|
||||||
|
if (!a || a.error || !a.id) return null;
|
||||||
|
return _dzArtist(a);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// Deezer artist photo (for the hero on non-local artist pages). iTunes artist entities had none.
|
// Deezer artist photo (for the hero on non-local artist pages). iTunes artist entities had none.
|
||||||
export async function deezerArtistImage(artistId) {
|
export async function deezerArtistImage(artistId) {
|
||||||
try {
|
try {
|
||||||
@@ -375,7 +458,7 @@ export async function deezerArtistImage(artistId) {
|
|||||||
|
|
||||||
// "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search
|
// "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search
|
||||||
// fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }.
|
// fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }.
|
||||||
export async function itunesTopCharts() {
|
export async function deezerTopCharts() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
|
const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
@@ -386,7 +469,7 @@ export async function itunesTopCharts() {
|
|||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function itunesTopSongs() {
|
export async function deezerTopSongs() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
|
const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|||||||
+24
-11
@@ -1,7 +1,11 @@
|
|||||||
import { S } from './state.js';
|
import { S } from './state.js';
|
||||||
import { toast } from './utils.js';
|
import { toast } from './utils.js';
|
||||||
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
|
import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from './api.js';
|
||||||
import { injectPWA } from './pwa.js';
|
import { injectPWA } from './pwa.js';
|
||||||
|
import { initPlayLog } from './player/scrobbleLog.js';
|
||||||
|
import { openStats, closeStats } from './ui/stats.js';
|
||||||
|
import { openCompare, closeCompare } from './ui/compare.js';
|
||||||
|
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
|
||||||
import { loadSavedTheme } from './themes.js';
|
import { loadSavedTheme } from './themes.js';
|
||||||
import { initScrollRows } from './ui/components.js';
|
import { initScrollRows } from './ui/components.js';
|
||||||
import {
|
import {
|
||||||
@@ -15,12 +19,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
|
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
|
||||||
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
|
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
|
||||||
searchSimilarArtist, searchAndPreview, loadItunesAlbum, closeEventsModal,
|
searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal,
|
||||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||||
} from './views/views.js';
|
} from './views/views.js';
|
||||||
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||||
onImporterInput, resolveImporterLink } from './views/spotify.js';
|
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve } from './views/spotify.js';
|
||||||
import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js';
|
import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js';
|
||||||
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js';
|
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js';
|
||||||
import {
|
import {
|
||||||
@@ -59,17 +63,18 @@ async function doConnect() {
|
|||||||
const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })();
|
const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })();
|
||||||
const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || '';
|
const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || '';
|
||||||
const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,'');
|
const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,'');
|
||||||
|
const scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,'');
|
||||||
if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
|
if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
|
||||||
const btn = document.getElementById('btn-connect');
|
const btn = document.getElementById('btn-connect');
|
||||||
btn.disabled=true; btn.textContent='Verbinde…';
|
btn.disabled=true; btn.textContent='Verbinde…';
|
||||||
document.getElementById('setup-error').style.display='none';
|
document.getElementById('setup-error').style.display='none';
|
||||||
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl};
|
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, scrobbleUrl:scrobUrl};
|
||||||
try {
|
try {
|
||||||
await api('ping');
|
await api('ping');
|
||||||
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
|
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
|
||||||
startApp();
|
startApp();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
|
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
|
||||||
_showSetupError('Verbindung fehlgeschlagen: '+e.message);
|
_showSetupError('Verbindung fehlgeschlagen: '+e.message);
|
||||||
btn.disabled=false; btn.textContent='Verbinden';
|
btn.disabled=false; btn.textContent='Verbinden';
|
||||||
}
|
}
|
||||||
@@ -80,7 +85,7 @@ function disconnect() {
|
|||||||
localStorage.removeItem('ns_cfg');
|
localStorage.removeItem('ns_cfg');
|
||||||
localStorage.removeItem('ns_last_played');
|
localStorage.removeItem('ns_last_played');
|
||||||
sessionStorage.removeItem('ns_nav');
|
sessionStorage.removeItem('ns_nav');
|
||||||
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
|
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
|
||||||
S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
|
S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
|
||||||
S.audio.pause(); S.audio.src='';
|
S.audio.pause(); S.audio.src='';
|
||||||
document.getElementById('app').style.display='none';
|
document.getElementById('app').style.display='none';
|
||||||
@@ -107,6 +112,8 @@ async function startApp() {
|
|||||||
navigate('home');
|
navigate('home');
|
||||||
}
|
}
|
||||||
initNotifBell();
|
initNotifBell();
|
||||||
|
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
|
||||||
|
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
|
||||||
// Wait for overrides before restoring player bar so disp* functions have the cache ready
|
// Wait for overrides before restoring player bar so disp* functions have the cache ready
|
||||||
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
|
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
|
||||||
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
|
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
|
||||||
@@ -132,6 +139,7 @@ window.addEventListener('load', () => {
|
|||||||
if(saved) {
|
if(saved) {
|
||||||
S.cfg = JSON.parse(saved);
|
S.cfg = JSON.parse(saved);
|
||||||
if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,'');
|
if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,'');
|
||||||
|
if(!S.cfg.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,'');
|
||||||
startApp();
|
startApp();
|
||||||
updateNpColorMode();
|
updateNpColorMode();
|
||||||
}
|
}
|
||||||
@@ -197,10 +205,12 @@ window.addEventListener('load', () => {
|
|||||||
// Skip when native is active: Media3 owns the system media session.
|
// Skip when native is active: Media3 owns the system media session.
|
||||||
if(navigator.mediaSession && !S._trackChanging && !window.NSBridge)
|
if(navigator.mediaSession && !S._trackChanging && !window.NSBridge)
|
||||||
navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing';
|
navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing';
|
||||||
// Only auto-resume if audio was actually playing when the page hid (OS-killed buffer).
|
// Only auto-resume after an INVOLUNTARY background stop (OS-killed buffer / frozen tab).
|
||||||
// If the user explicitly paused, _wasPlayingBeforeHide is false — don't restart.
|
// Never restart if the user deliberately paused — including via the media notification while
|
||||||
|
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
|
||||||
|
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
|
||||||
if(!S.audio.paused) return;
|
if(!S.audio.paused) return;
|
||||||
if(s && S.audio.src && _wasPlayingBeforeHide) S.audio.play().catch(() => { S._trackChanging = false; });
|
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -314,7 +324,7 @@ Object.assign(window, {
|
|||||||
// Navigation
|
// Navigation
|
||||||
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
||||||
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
||||||
loadItunesAlbum, closeEventsModal,
|
loadDeezerAlbum, closeEventsModal,
|
||||||
// Player
|
// Player
|
||||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||||
@@ -326,11 +336,14 @@ Object.assign(window, {
|
|||||||
// UI / Modals
|
// UI / Modals
|
||||||
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
||||||
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
|
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
|
||||||
|
openStats, closeStats,
|
||||||
|
openCompare, closeCompare,
|
||||||
|
closeArtistPin, clearArtistPin,
|
||||||
// Views
|
// Views
|
||||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||||
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||||
onImporterInput, resolveImporterLink,
|
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve,
|
||||||
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
||||||
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
|
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
|
||||||
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
||||||
|
|||||||
+18
-9
@@ -4,6 +4,7 @@ import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtis
|
|||||||
// views.js/ui.js circular imports — safe (only used inside function bodies)
|
// views.js/ui.js circular imports — safe (only used inside function bodies)
|
||||||
import { loadRecommendations } from '../views/views.js';
|
import { loadRecommendations } from '../views/views.js';
|
||||||
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
|
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
|
||||||
|
import { logPlay } from './scrobbleLog.js';
|
||||||
|
|
||||||
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
|
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
|
||||||
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
|
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
|
||||||
@@ -20,14 +21,15 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
|
|||||||
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
|
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
|
||||||
let _scrobbleTimer = null;
|
let _scrobbleTimer = null;
|
||||||
let _scrobbleId = null;
|
let _scrobbleId = null;
|
||||||
function _scheduleScrobble(id, durSec) {
|
function _scheduleScrobble(s) {
|
||||||
if (_scrobbleId === String(id)) return;
|
const id = s?.id;
|
||||||
|
if (!id || _scrobbleId === String(id)) return;
|
||||||
_cancelScrobble();
|
_cancelScrobble();
|
||||||
_scrobbleId = String(id);
|
_scrobbleId = String(id);
|
||||||
const mode = S.cfg.scrobbleMode || '15s';
|
const mode = S.cfg.scrobbleMode || '15s';
|
||||||
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
|
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
|
||||||
let delay;
|
let delay;
|
||||||
const dur = (durSec || 0) * 1000;
|
const dur = (s?.duration || 0) * 1000;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case '30s': delay = 30_000; break;
|
case '30s': delay = 30_000; break;
|
||||||
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
|
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
|
||||||
@@ -35,7 +37,10 @@ function _scheduleScrobble(id, durSec) {
|
|||||||
default: delay = 15_000; break;
|
default: delay = 15_000; break;
|
||||||
}
|
}
|
||||||
_scrobbleTimer = setTimeout(() => {
|
_scrobbleTimer = setTimeout(() => {
|
||||||
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
|
if (_scrobbleId) {
|
||||||
|
api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
|
||||||
|
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
|
||||||
|
}
|
||||||
_scrobbleId = null;
|
_scrobbleId = null;
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,10 @@ function _cancelScrobble() {
|
|||||||
_scrobbleId = null;
|
_scrobbleId = null;
|
||||||
}
|
}
|
||||||
function _scrobbleNow(s) {
|
function _scrobbleNow(s) {
|
||||||
if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
|
if (s) {
|
||||||
|
api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
|
||||||
|
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
|
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
|
||||||
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
|
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
|
||||||
@@ -214,7 +222,7 @@ window.NSPlayer = {
|
|||||||
try {
|
try {
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
_scheduleScrobble(s.id, s.duration);
|
_scheduleScrobble(s);
|
||||||
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
|
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
@@ -388,6 +396,7 @@ export function playQueue(songs, startIdx, opts = {}) {
|
|||||||
|
|
||||||
export function playCurrent() {
|
export function playCurrent() {
|
||||||
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
|
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
|
||||||
|
S._userPaused = false; // starting a track is intent to play — clear any stale pause intent
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
|
|
||||||
if (HAS_BRIDGE) {
|
if (HAS_BRIDGE) {
|
||||||
@@ -411,7 +420,7 @@ export function playCurrent() {
|
|||||||
onNpSongChange(s.id);
|
onNpSongChange(s.id);
|
||||||
renderQueue();
|
renderQueue();
|
||||||
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
|
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
|
||||||
_scheduleScrobble(s.id, s.duration);
|
_scheduleScrobble(s);
|
||||||
|
|
||||||
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
||||||
|
|
||||||
@@ -653,8 +662,8 @@ export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
|
|||||||
export function togglePlay() {
|
export function togglePlay() {
|
||||||
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
|
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
|
||||||
if(S.audio.src) {
|
if(S.audio.src) {
|
||||||
if (S.audio.paused) { S.audio.play(); }
|
if (S.audio.paused) { S._userPaused = false; S.audio.play(); }
|
||||||
else { S.audio.pause(); _cancelScrobble(); }
|
else { S._userPaused = true; S.audio.pause(); _cancelScrobble(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// ─── NovaSonic play-log client ──────────────────────────────────────────────
|
||||||
|
// Records a timestamped play into our own scrobble service (separate origin,
|
||||||
|
// S.cfg.scrobbleUrl) — the foundation for day/month/year stats + Wrapped.
|
||||||
|
//
|
||||||
|
// This runs IN ADDITION to the existing Navidrome scrobble (which feeds playCount
|
||||||
|
// and, if configured, Last.fm). It never replaces it. We fire logPlay() at the
|
||||||
|
// exact moment NovaSonic decides a play "counts" (see player.js), so the play-log
|
||||||
|
// and Last.fm stay consistent and both honour the user's scrobbleMode setting.
|
||||||
|
//
|
||||||
|
// Offline-safe: entries are queued in localStorage and flushed as an idempotent
|
||||||
|
// batch (server dedups by uuid), so a play made offline is delivered later exactly
|
||||||
|
// once.
|
||||||
|
|
||||||
|
import { S } from '../state.js';
|
||||||
|
import { subsonicAuthParams } from '../api.js';
|
||||||
|
|
||||||
|
const _QUEUE_KEY = 'ns_play_queue';
|
||||||
|
const _MAX_QUEUE = 500; // guard against unbounded growth if the service stays down
|
||||||
|
let _flushing = false;
|
||||||
|
|
||||||
|
function _uuid() {
|
||||||
|
try { return crypto.randomUUID(); }
|
||||||
|
catch { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); }
|
||||||
|
}
|
||||||
|
function _load() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(_QUEUE_KEY) || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
function _save(q) {
|
||||||
|
try { localStorage.setItem(_QUEUE_KEY, JSON.stringify(q.slice(-_MAX_QUEUE))); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record one counted play. `song` is a Subsonic song object; source 'web' | 'android'.
|
||||||
|
export function logPlay(song, source = 'web') {
|
||||||
|
if (!S.cfg?.scrobbleUrl || !song?.id) return;
|
||||||
|
const entry = {
|
||||||
|
uuid: _uuid(),
|
||||||
|
songId: String(song.id),
|
||||||
|
title: song.title ?? null,
|
||||||
|
artist: song.artist ?? null,
|
||||||
|
artistId: song.artistId ?? null,
|
||||||
|
album: song.album ?? null,
|
||||||
|
albumId: song.albumId ?? null,
|
||||||
|
coverArt: song.coverArt ?? null, // Subsonic coverArt token — what getCoverArt actually needs
|
||||||
|
genre: song.genre ?? null,
|
||||||
|
duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null,
|
||||||
|
playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
const q = _load();
|
||||||
|
q.push(entry);
|
||||||
|
_save(q);
|
||||||
|
flushPlayQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver everything queued as one idempotent batch. Safe to call repeatedly.
|
||||||
|
export async function flushPlayQueue() {
|
||||||
|
if (_flushing || !S.cfg?.scrobbleUrl) return;
|
||||||
|
const batch = _load();
|
||||||
|
if (!batch.length) return;
|
||||||
|
const auth = subsonicAuthParams();
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
_flushing = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/plays?${auth}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ plays: batch }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return; // 401/5xx/network → keep the queue, retry later
|
||||||
|
// Success: the batch is handled (accepted + duplicates are both durable; invalid
|
||||||
|
// entries are malformed and will never succeed). Remove exactly what we sent by
|
||||||
|
// uuid, preserving any plays queued while the request was in flight.
|
||||||
|
const sent = new Set(batch.map(e => e.uuid));
|
||||||
|
_save(_load().filter(e => !sent.has(e.uuid)));
|
||||||
|
} catch {
|
||||||
|
// network error → keep queue for the next flush
|
||||||
|
} finally {
|
||||||
|
_flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on app start and whenever connectivity returns.
|
||||||
|
export function initPlayLog() {
|
||||||
|
flushPlayQueue();
|
||||||
|
window.addEventListener('online', flushPlayQueue);
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
// Shared mutable state — imported as { S } in every module
|
// Shared mutable state — imported as { S } in every module
|
||||||
export const S = {
|
export const S = {
|
||||||
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false },
|
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', scrobbleUrl: '', showPlayCount: false, showRomaji: false },
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
queue: [],
|
queue: [],
|
||||||
@@ -15,6 +15,7 @@ export const S = {
|
|||||||
_scrubbing: false,
|
_scrubbing: false,
|
||||||
_msPosLast: 0,
|
_msPosLast: 0,
|
||||||
_trackChanging: false,
|
_trackChanging: false,
|
||||||
|
_userPaused: false, // true when the user deliberately paused → foreground must NOT auto-resume
|
||||||
queueOpen: false,
|
queueOpen: false,
|
||||||
|
|
||||||
// Now Playing overlay
|
// Now Playing overlay
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// ─── Artist pin picker ───────────────────────────────────────────────────────
|
||||||
|
// Opened from the artist page ("⚙ Falscher Künstler?"). Lets the user pin a local
|
||||||
|
// (Navidrome) artist to a specific Deezer artist, fixing same-name collisions where
|
||||||
|
// NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
|
||||||
|
// The mapping is stored server-side (shared across users) — see api.js.
|
||||||
|
|
||||||
|
import { deezerSearch, deezerLookupArtist, setArtistOverride, removeArtistOverride, getArtistOverride } from '../api.js';
|
||||||
|
import { esc, fmtCount, toast } from '../utils.js';
|
||||||
|
|
||||||
|
// Pull a Deezer artist id out of whatever the user pastes: a full URL
|
||||||
|
// (deezer.com/en/artist/80239872, /de/artist/…?…), or a bare id ("80239872").
|
||||||
|
function _parseDeezerArtistId(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const m = s.match(/artist\/(\d+)/i);
|
||||||
|
if (m) return m[1];
|
||||||
|
if (/^\d+$/.test(s)) return s;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _overlay = null;
|
||||||
|
let _ctx = null; // { ndArtistId, ndName }
|
||||||
|
|
||||||
|
export function openArtistPin(ndArtistId, ndName) {
|
||||||
|
_ctx = { ndArtistId: String(ndArtistId), ndName: ndName || '' };
|
||||||
|
if (!_overlay) _build();
|
||||||
|
_overlay.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeArtistPin() {
|
||||||
|
if (_overlay) _overlay.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _build() {
|
||||||
|
_overlay = document.createElement('div');
|
||||||
|
_overlay.className = 'modal-overlay';
|
||||||
|
_overlay.id = 'artist-pin-overlay';
|
||||||
|
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeArtistPin(); });
|
||||||
|
_overlay.innerHTML = `
|
||||||
|
<div class="modal-card" style="max-width:520px;width:calc(100vw - 32px);max-height:86vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||||
|
<span style="font-size:18px">⚙</span>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:16px;font-weight:800">Richtigen Künstler wählen</div>
|
||||||
|
<div id="artist-pin-sub" style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeArtistPin()" style="background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer;padding:6px 10px">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="artist-pin-body" style="padding:12px 14px;overflow-y:auto"></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(_overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _render() {
|
||||||
|
_overlay.querySelector('#artist-pin-sub').textContent = `Für „${_ctx.ndName}"`;
|
||||||
|
const body = _overlay.querySelector('#artist-pin-body');
|
||||||
|
const cur = getArtistOverride(_ctx.ndArtistId);
|
||||||
|
const clearBtn = cur
|
||||||
|
? `<button onclick="clearArtistPin()" style="width:100%;padding:10px;margin-bottom:10px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--danger);font-family:var(--font);font-weight:600;cursor:pointer">Pin entfernen (aktuell: ${esc(cur.name || 'gepinnt')})</button>`
|
||||||
|
: '';
|
||||||
|
// Deezer's /search/artist is a ranked, incomplete fuzzy match — low-profile same-name artists
|
||||||
|
// (e.g. Nana id 80239872) never surface. Paste the Deezer link/id to pin directly (reliable route).
|
||||||
|
const linkBox = `
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||||||
|
<input id="artist-pin-link" type="text" placeholder="Deezer-Link oder ID einfügen…"
|
||||||
|
style="flex:1;min-width:0;padding:10px 12px;border-radius:9px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--font);font-size:13px">
|
||||||
|
<button id="artist-pin-link-btn" style="padding:0 16px;border-radius:9px;border:none;background:var(--accent);color:#fff;font-family:var(--font);font-weight:700;cursor:pointer;white-space:nowrap">Pinnen</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--text2);margin:-6px 2px 12px">Nicht in der Liste? Öffne den Künstler auf deezer.com und füge den Link ein.</div>`;
|
||||||
|
// Render the shell once (clear button + link box stay put); only the results list re-renders,
|
||||||
|
// so a value the user is typing into the link field survives the async search resolving.
|
||||||
|
body.innerHTML = clearBtn + linkBox + `<div id="artist-pin-results"><div style="text-align:center;color:var(--text2);padding:24px 0">Suche…</div></div>`;
|
||||||
|
_wireLinkBox();
|
||||||
|
const resultsEl = body.querySelector('#artist-pin-results');
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
try { results = (await deezerSearch(_ctx.ndName, 'musicArtist', 8)) || []; } catch {}
|
||||||
|
|
||||||
|
const rows = results.map(r => `
|
||||||
|
<div class="artist-pin-cand" data-id="${esc(r.artistId)}" data-name="${esc(r.artistName || '')}" data-img="${esc(r.artworkUrl100 || '')}" data-fans="${r.fanCount ?? ''}"
|
||||||
|
style="display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;cursor:pointer" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
|
||||||
|
<div style="width:48px;height:48px;border-radius:50%;background:var(--surface2);overflow:hidden;flex-shrink:0">
|
||||||
|
${r.artworkUrl100 ? `<img src="${esc(r.artworkUrl100)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(r.artistName || '—')}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2)">${r.fanCount != null ? fmtCount(r.fanCount) + ' Fans' : 'Deezer'}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
resultsEl.innerHTML = rows || `<div style="text-align:center;color:var(--text2);padding:24px 0">Keine Deezer-Künstler gefunden.</div>`;
|
||||||
|
resultsEl.querySelectorAll('.artist-pin-cand').forEach(el => el.addEventListener('click', () => _pick(el.dataset)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the "paste a Deezer link/id" box → look the artist up directly and pin it.
|
||||||
|
function _wireLinkBox() {
|
||||||
|
const input = _overlay.querySelector('#artist-pin-link');
|
||||||
|
const btn = _overlay.querySelector('#artist-pin-link-btn');
|
||||||
|
if (!input || !btn) return;
|
||||||
|
const go = async () => {
|
||||||
|
const id = _parseDeezerArtistId(input.value);
|
||||||
|
if (!id) { toast('Kein gültiger Deezer-Link / keine ID'); return; }
|
||||||
|
btn.disabled = true; btn.textContent = '…';
|
||||||
|
const a = await deezerLookupArtist(id);
|
||||||
|
btn.disabled = false; btn.textContent = 'Pinnen';
|
||||||
|
if (!a) { toast('Deezer-Künstler nicht gefunden'); return; }
|
||||||
|
await _pick({ id: String(a.artistId), name: a.artistName || _ctx.ndName, img: a.artworkUrl100 || '', fans: a.fanCount != null ? String(a.fanCount) : '' });
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', go);
|
||||||
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pick(ds) {
|
||||||
|
await setArtistOverride(_ctx.ndArtistId, {
|
||||||
|
deezerArtistId: ds.id,
|
||||||
|
name: ds.name,
|
||||||
|
image: ds.img,
|
||||||
|
fanCount: ds.fans !== '' ? Number(ds.fans) : null,
|
||||||
|
});
|
||||||
|
closeArtistPin();
|
||||||
|
_reloadArtist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearArtistPin() {
|
||||||
|
if (_ctx) await removeArtistOverride(_ctx.ndArtistId);
|
||||||
|
closeArtistPin();
|
||||||
|
_reloadArtist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _reloadArtist() {
|
||||||
|
const { navigate } = await import('../views/views.js');
|
||||||
|
navigate('artist-detail', { id: _ctx.ndArtistId, name: _ctx.ndName });
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
|
||||||
|
// Opened from Settings ("Musik-Match"). Compares the logged-in user's play-log
|
||||||
|
// against another user's (GET /scrobble/compare) and renders a taste-match score
|
||||||
|
// (cosine over artist-play vectors), shared artists/tracks, and each side's unique
|
||||||
|
// top artists. The partner is chosen from a picker fed by GET /scrobble/users.
|
||||||
|
// Self-contained overlay, mirroring stats.js.
|
||||||
|
|
||||||
|
import { S } from '../state.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { subsonicAuthParams, coverUrl } from '../api.js';
|
||||||
|
|
||||||
|
let _overlay = null;
|
||||||
|
let _me = null; // authed username (userA)
|
||||||
|
let _partner = null; // chosen userB
|
||||||
|
let _users = []; // [{ user, plays, lastPlayedAt }]
|
||||||
|
|
||||||
|
export function openCompare() {
|
||||||
|
if (!_overlay) _buildOverlay();
|
||||||
|
_overlay.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
_loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeCompare() {
|
||||||
|
if (_overlay) _overlay.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from the rendered partner list (delegated below) — kept module-local.
|
||||||
|
function _choosePartner(name) { _partner = name; _loadCompare(); }
|
||||||
|
|
||||||
|
function _buildOverlay() {
|
||||||
|
_overlay = document.createElement('div');
|
||||||
|
_overlay.className = 'modal-overlay';
|
||||||
|
_overlay.id = 'compare-overlay';
|
||||||
|
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
|
||||||
|
_overlay.innerHTML = `
|
||||||
|
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||||
|
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||||
|
<span style="font-size:18px">🎧</span>
|
||||||
|
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
|
||||||
|
<button id="compare-back" title="Andere Person" style="display:none;padding:6px 10px;color:var(--text2);font-size:14px;font-weight:600;background:none;border:none;cursor:pointer">↺ Wechseln</button>
|
||||||
|
<button class="hbtn" onclick="closeCompare()" title="Schließen"
|
||||||
|
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="compare-body" id="compare-body" style="padding:16px 18px;overflow-y:auto;min-height:50vh"></div>
|
||||||
|
</div>`;
|
||||||
|
// Delegated: pick a partner, or navigate to an artist/album row.
|
||||||
|
_overlay.querySelector('#compare-body').addEventListener('click', async e => {
|
||||||
|
const pick = e.target.closest('[data-partner]');
|
||||||
|
if (pick) { _choosePartner(pick.dataset.partner); return; }
|
||||||
|
const row = e.target.closest('[data-nav-type]');
|
||||||
|
if (!row) return;
|
||||||
|
const { navType, navId, navName, navArtist } = row.dataset;
|
||||||
|
closeCompare();
|
||||||
|
const { navigate } = await import('../views/views.js');
|
||||||
|
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
|
||||||
|
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
|
||||||
|
});
|
||||||
|
_overlay.querySelector('#compare-back').addEventListener('click', () => { _partner = null; _renderPicker(); });
|
||||||
|
document.body.appendChild(_overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errBox(msg) {
|
||||||
|
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
|
||||||
|
}
|
||||||
|
function _info(msg) {
|
||||||
|
return `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadUsers() {
|
||||||
|
const body = _overlay.querySelector('#compare-body');
|
||||||
|
_overlay.querySelector('#compare-back').style.display = 'none';
|
||||||
|
_partner = null;
|
||||||
|
if (!S.cfg.scrobbleUrl) {
|
||||||
|
body.innerHTML = _info('Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auth = subsonicAuthParams();
|
||||||
|
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
|
||||||
|
body.innerHTML = _info('Lade…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/users?${auth}`);
|
||||||
|
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
|
||||||
|
const d = await res.json();
|
||||||
|
_me = d.me;
|
||||||
|
_users = (d.users || []).filter(u => u.user !== _me);
|
||||||
|
_renderPicker();
|
||||||
|
} catch {
|
||||||
|
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPicker() {
|
||||||
|
const body = _overlay.querySelector('#compare-body');
|
||||||
|
_overlay.querySelector('#compare-back').style.display = 'none';
|
||||||
|
if (!_users.length) {
|
||||||
|
body.innerHTML = _info('Noch niemand sonst hat Wiedergaben aufgezeichnet.<br>Sobald eine andere Person NovaSonic nutzt, kannst du euch hier vergleichen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = _users.map(u => `
|
||||||
|
<div data-partner="${esc(u.user)}" style="display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;cursor:pointer;border:1px solid var(--border);margin-bottom:8px"
|
||||||
|
onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
|
||||||
|
<div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-weight:800;color:#000;flex-shrink:0">${esc((u.user[0] || '?').toUpperCase())}</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(u.user)}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2)">${u.plays.toLocaleString('de-DE')} Plays</div>
|
||||||
|
</div>
|
||||||
|
<div style="color:var(--text2);font-size:18px">›</div>
|
||||||
|
</div>`).join('');
|
||||||
|
body.innerHTML = `<div style="font-size:13px;color:var(--text2);margin:2px 2px 14px">Mit wem willst du dich vergleichen, <b>${esc(_me || '')}</b>?</div>${rows}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadCompare() {
|
||||||
|
const body = _overlay.querySelector('#compare-body');
|
||||||
|
_overlay.querySelector('#compare-back').style.display = 'inline-block';
|
||||||
|
const auth = subsonicAuthParams();
|
||||||
|
body.innerHTML = _info('Vergleiche…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/compare?${auth}&with=${encodeURIComponent(_partner)}`);
|
||||||
|
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
|
||||||
|
_renderCompare(body, await res.json());
|
||||||
|
} catch {
|
||||||
|
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderCompare(body, d) {
|
||||||
|
const score = Math.max(0, Math.min(100, d.matchScore || 0));
|
||||||
|
const verdict =
|
||||||
|
score >= 80 ? 'Seelenverwandte 🎯' :
|
||||||
|
score >= 60 ? 'Starkes Match 🔥' :
|
||||||
|
score >= 40 ? 'Solide Überschneidung 👍' :
|
||||||
|
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
|
||||||
|
'Ziemlich unterschiedlich 🌗';
|
||||||
|
|
||||||
|
body.innerHTML = [
|
||||||
|
_scoreRing(d, score, verdict),
|
||||||
|
_totals(d),
|
||||||
|
_sharedArtists(d.sharedArtists),
|
||||||
|
_sharedTracks(d.sharedTracks),
|
||||||
|
_uniqueCols(d),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scoreRing(d, score, verdict) {
|
||||||
|
const deg = Math.round(score * 3.6);
|
||||||
|
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
|
||||||
|
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
|
||||||
|
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
|
||||||
|
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
|
||||||
|
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> ⟷ <b>${esc(d.userB)}</b></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _totals(d) {
|
||||||
|
const col = (name, t) => `
|
||||||
|
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;min-width:0">
|
||||||
|
<div style="font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px">${esc(name)}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2)">${(t.plays || 0).toLocaleString('de-DE')} Plays · ${(t.artists || 0).toLocaleString('de-DE')} Künstler</div>
|
||||||
|
</div>`;
|
||||||
|
return `<div style="display:flex;gap:10px;margin-bottom:22px">${col(d.userA, d.totalsA)}${col(d.userB, d.totalsB)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _h(t) {
|
||||||
|
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 10px">${esc(t)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared artist row: a centered name with each side's play count on its flank + a
|
||||||
|
// mirrored bar so you can see who plays them more.
|
||||||
|
function _sharedArtists(items) {
|
||||||
|
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
|
||||||
|
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
|
||||||
|
const rows = items.slice(0, 15).map(a => {
|
||||||
|
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
|
||||||
|
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
|
||||||
|
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
|
||||||
|
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||||
|
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
|
||||||
|
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
|
||||||
|
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sharedTracks(items) {
|
||||||
|
if (!items?.length) return '';
|
||||||
|
const rows = items.slice(0, 12).map(t => {
|
||||||
|
const cover = coverUrl(t.coverArt || t.albumId || t.songId, 80);
|
||||||
|
const nav = t.albumId ? `data-nav-type="album" data-nav-id="${esc(t.albumId)}" data-nav-name="${esc(t.album || '')}" data-nav-artist="${esc(t.artist || '')}" style="cursor:pointer"` : '';
|
||||||
|
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
||||||
|
<div style="width:40px;height:40px;border-radius:6px;background:var(--surface2);flex-shrink:0;overflow:hidden">
|
||||||
|
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.title || '—')}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.artist || '')}</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2);font-weight:600;white-space:nowrap">${t.playsA} · ${t.playsB}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `${_h('Gemeinsame Songs')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _uniqueCols(d) {
|
||||||
|
const list = (title, arr) => {
|
||||||
|
const rows = (arr || []).slice(0, 6).map(a => {
|
||||||
|
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
|
||||||
|
return `<div ${nav} style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px">
|
||||||
|
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.name)}</span>
|
||||||
|
<span style="color:var(--text2);font-size:12px;font-weight:600">${a.plays}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || `<div style="color:var(--text2);font-size:13px;padding:4px 0">—</div>`;
|
||||||
|
return `<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(title)}</div>${rows}</div>`;
|
||||||
|
};
|
||||||
|
return `${_h('Nur bei…')}<div style="display:flex;gap:18px">${list(`Nur ${d.userA}`, d.onlyA)}${list(`Nur ${d.userB}`, d.onlyB)}</div>`;
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, singlePlay =
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GLOBAL TRACK ITEM (iTunes / Spotify results) ───
|
// ─── GLOBAL TRACK ITEM (Deezer / Spotify results) ───
|
||||||
export function makeGlobalTrackItem(t, idx, list) {
|
export function makeGlobalTrackItem(t, idx, list) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'gtrack-item';
|
el.className = 'gtrack-item';
|
||||||
|
|||||||
+8
-5
@@ -1,13 +1,16 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc, toast } from '../utils.js';
|
import { esc, toast } from '../utils.js';
|
||||||
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, itunesLookupAlbum } from '../api.js';
|
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, deezerLookupAlbum } from '../api.js';
|
||||||
import { _addNotif } from './notify.js';
|
import { _addNotif } from './notify.js';
|
||||||
|
|
||||||
// ─── DOWNLOAD MODAL ───
|
// ─── DOWNLOAD MODAL ───
|
||||||
export function openDlModal(track) {
|
export function openDlModal(track) {
|
||||||
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
|
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
|
||||||
S.downloadTarget = track; S.dlType = 'track';
|
// Album cards pass isAlbum → default the modal to an album download, not a single track named
|
||||||
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track'));
|
// after the album (a one-click download of an album card used to grab it as one "track").
|
||||||
|
const _defType = track.isAlbum ? 'album' : 'track';
|
||||||
|
S.downloadTarget = track; S.dlType = _defType;
|
||||||
|
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type===_defType));
|
||||||
const info = document.getElementById('dl-modal-info');
|
const info = document.getElementById('dl-modal-info');
|
||||||
const art = track.artworkUrl100 || track.artworkUrl60 || '';
|
const art = track.artworkUrl100 || track.artworkUrl60 || '';
|
||||||
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
|
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
|
||||||
@@ -35,7 +38,7 @@ export async function executeDownload() {
|
|||||||
title: _title,
|
title: _title,
|
||||||
album: _album,
|
album: _album,
|
||||||
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
|
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
|
||||||
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
|
deezerId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
|
||||||
collectionId: S.downloadTarget.collectionId || null,
|
collectionId: S.downloadTarget.collectionId || null,
|
||||||
explicit: S.downloadTarget.trackExplicitness === 'explicit',
|
explicit: S.downloadTarget.trackExplicitness === 'explicit',
|
||||||
trackNum: S.downloadTarget.trackNumber || undefined,
|
trackNum: S.downloadTarget.trackNumber || undefined,
|
||||||
@@ -51,7 +54,7 @@ export async function executeDownload() {
|
|||||||
// request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup).
|
// request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup).
|
||||||
if (S.dlType === 'album' && payload.collectionId) {
|
if (S.dlType === 'album' && payload.collectionId) {
|
||||||
try {
|
try {
|
||||||
const albumTracks = await itunesLookupAlbum(payload.collectionId);
|
const albumTracks = await deezerLookupAlbum(payload.collectionId);
|
||||||
if (albumTracks.length) payload.tracks = albumTracks;
|
if (albumTracks.length) payload.tracks = albumTracks;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -121,16 +121,21 @@ export async function _notifNavigate(notifId) {
|
|||||||
try {
|
try {
|
||||||
const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`);
|
const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`);
|
||||||
const albums = r.searchResult3?.album || [];
|
const albums = r.searchResult3?.album || [];
|
||||||
const match = albums.find(a => norm(a.name) === norm(albumQuery) && norm(a.artist) === norm(n.navArtist))
|
const na = norm(n.navArtist);
|
||||||
|| albums.find(a => norm(a.name) === norm(albumQuery))
|
const nameEq = a => norm(a.name) === norm(albumQuery);
|
||||||
|| albums[0];
|
// Fuzzy artist compare (multi-artist / album-artist drift), but REQUIRE it when we know the
|
||||||
|
// artist: several artists share an album title ("I LIKE IT!"), and a name-only fallback used
|
||||||
|
// to open a completely different artist's album — the notification led to the wrong song.
|
||||||
|
const artistEq = a => { const aa = norm(a.artist); return !!na && (aa === na || aa.includes(na) || na.includes(aa)); };
|
||||||
|
const match = albums.find(a => nameEq(a) && artistEq(a))
|
||||||
|
|| (na ? null : albums.find(nameEq)); // name-only only when the artist is unknown
|
||||||
if (match) {
|
if (match) {
|
||||||
navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' });
|
navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
toast('Album nicht gefunden — Library-Scan nötig?');
|
toast('Album noch nicht gefunden — evtl. wird die Library gerade gescannt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +143,9 @@ export async function _notifNavigate(notifId) {
|
|||||||
try {
|
try {
|
||||||
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
|
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
|
||||||
const artists = r.searchResult3?.artist || [];
|
const artists = r.searchResult3?.artist || [];
|
||||||
const match = artists.find(a => norm(a.name) === norm(n.navArtist)) || artists[0];
|
const match = artists.find(a => norm(a.name) === norm(n.navArtist)); // no blind artists[0]
|
||||||
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
|
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
|
||||||
} catch {}
|
} catch {}
|
||||||
toast('Künstler nicht gefunden — Library-Scan nötig?');
|
toast('Künstler noch nicht gefunden — evtl. wird die Library gerade gescannt');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,27 @@ export function openSettings() {
|
|||||||
_renderThemePicker();
|
_renderThemePicker();
|
||||||
document.getElementById('settings-modal').classList.add('open');
|
document.getElementById('settings-modal').classList.add('open');
|
||||||
checkBackend();
|
checkBackend();
|
||||||
|
_loadApkInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the "download the Android APK" button only if the backend actually has an APK staged.
|
||||||
|
async function _loadApkInfo() {
|
||||||
|
const section = document.getElementById('s-apk-section');
|
||||||
|
const btn = document.getElementById('s-apk-btn');
|
||||||
|
const meta = document.getElementById('s-apk-meta');
|
||||||
|
if (!section || !btn) return;
|
||||||
|
section.style.display = 'none';
|
||||||
|
const base = S.cfg.backendUrl;
|
||||||
|
if (!base) return;
|
||||||
|
try {
|
||||||
|
const info = await fetch(`${base}/api/apk/info`).then(r => r.json());
|
||||||
|
if (!info?.available) return;
|
||||||
|
btn.href = `${base}/api/apk`;
|
||||||
|
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' MB' : '';
|
||||||
|
const date = info.updatedAt ? new Date(info.updatedAt).toLocaleDateString('de-DE') : '';
|
||||||
|
if (meta) meta.textContent = [info.version && `v${info.version}`, mb, date && `Stand ${date}`].filter(Boolean).join(' · ');
|
||||||
|
section.style.display = '';
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderThemePicker() {
|
function _renderThemePicker() {
|
||||||
|
|||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
// ─── Listening statistics view ──────────────────────────────────────────────
|
||||||
|
// Opened from Settings ("Meine Statistiken"). Reads the self-hosted scrobble
|
||||||
|
// service (S.cfg.scrobbleUrl) → GET /scrobble/stats and renders totals, top
|
||||||
|
// artists/albums/tracks, a per-bucket play series, and a 24h listening clock.
|
||||||
|
// Self-contained: builds its own overlay DOM, so no index.html markup is needed.
|
||||||
|
|
||||||
|
import { S } from '../state.js';
|
||||||
|
import { esc, formatDur } from '../utils.js';
|
||||||
|
import { subsonicAuthParams, coverUrl } from '../api.js';
|
||||||
|
|
||||||
|
let _overlay = null;
|
||||||
|
|
||||||
|
// Time-travel navigator: browse all-time, a specific year, or a specific month.
|
||||||
|
let _view = 'all'; // all | year | month
|
||||||
|
let _year = new Date().getFullYear();
|
||||||
|
let _month = new Date().getMonth(); // 0-11
|
||||||
|
let _firstPlayedAt = null; // epoch secs of the user's earliest play → navigator lower bound
|
||||||
|
|
||||||
|
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
// Human listening time: "3h 12m" / "12m" / "45s"
|
||||||
|
function _fmtTime(sec) {
|
||||||
|
sec = Math.floor(sec || 0);
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
if (h) return `${h}h ${m}m`;
|
||||||
|
if (m) return `${m}m`;
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tzOffsetSec() { return -new Date().getTimezoneOffset() * 60; }
|
||||||
|
|
||||||
|
// from/to (epoch seconds) + series bucket for the current navigator state.
|
||||||
|
function _range() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (_view === 'year') {
|
||||||
|
return {
|
||||||
|
from: Math.floor(new Date(_year, 0, 1).getTime() / 1000),
|
||||||
|
to: Math.floor(new Date(_year + 1, 0, 1).getTime() / 1000),
|
||||||
|
bucket: 'month',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (_view === 'month') {
|
||||||
|
return {
|
||||||
|
from: Math.floor(new Date(_year, _month, 1).getTime() / 1000),
|
||||||
|
to: Math.floor(new Date(_year, _month + 1, 1).getTime() / 1000),
|
||||||
|
bucket: 'day',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { from: 0, to: now + 1, bucket: 'month' }; // all time
|
||||||
|
}
|
||||||
|
|
||||||
|
function _periodLabel() {
|
||||||
|
if (_view === 'year') return String(_year);
|
||||||
|
if (_view === 'month') return `${_MONTHS[_month]} ${_year}`;
|
||||||
|
return 'Alles';
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the shown period is the current (real) one — used to disable stepping into the future.
|
||||||
|
function _atCurrent() {
|
||||||
|
const n = new Date();
|
||||||
|
if (_view === 'year') return _year >= n.getFullYear();
|
||||||
|
if (_view === 'month') return _year > n.getFullYear() || (_year === n.getFullYear() && _month >= n.getMonth());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the shown period is at/before the user's first logged play — caps stepping backwards.
|
||||||
|
function _atStart() {
|
||||||
|
if (!_firstPlayedAt) return false;
|
||||||
|
const f = new Date(_firstPlayedAt * 1000);
|
||||||
|
if (_view === 'year') return _year <= f.getFullYear();
|
||||||
|
if (_view === 'month') return _year < f.getFullYear() || (_year === f.getFullYear() && _month <= f.getMonth());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _step(dir) {
|
||||||
|
if (_view === 'year') { _year += dir; }
|
||||||
|
else if (_view === 'month') {
|
||||||
|
_month += dir;
|
||||||
|
if (_month < 0) { _month = 11; _year--; }
|
||||||
|
if (_month > 11) { _month = 0; _year++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openStats() {
|
||||||
|
if (!_overlay) _buildOverlay();
|
||||||
|
_overlay.classList.add('open'); // .modal-overlay.open = visible (matches app's modals)
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeStats() {
|
||||||
|
if (_overlay) _overlay.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildOverlay() {
|
||||||
|
_overlay = document.createElement('div');
|
||||||
|
_overlay.className = 'modal-overlay';
|
||||||
|
_overlay.id = 'stats-overlay';
|
||||||
|
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeStats(); });
|
||||||
|
_overlay.innerHTML = `
|
||||||
|
<div class="modal-card stats-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||||
|
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||||
|
<span style="font-size:18px">📊</span>
|
||||||
|
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Meine Statistiken</div>
|
||||||
|
<button class="hbtn" onclick="closeStats()" title="Schließen"
|
||||||
|
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
|
||||||
|
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto;min-height:62vh"></div>
|
||||||
|
</div>`;
|
||||||
|
// Delegated navigation: click an artist / album / track row → open its page.
|
||||||
|
_overlay.querySelector('#stats-body').addEventListener('click', async e => {
|
||||||
|
const row = e.target.closest('[data-nav-type]');
|
||||||
|
if (!row) return;
|
||||||
|
const { navType, navId, navName, navArtist } = row.dataset;
|
||||||
|
closeStats();
|
||||||
|
const { navigate } = await import('../views/views.js');
|
||||||
|
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
|
||||||
|
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
|
||||||
|
});
|
||||||
|
document.body.appendChild(_overlay);
|
||||||
|
_renderTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderTabs() {
|
||||||
|
const tabs = _overlay.querySelector('#stats-tabs');
|
||||||
|
const scopes = [['all', 'Alles'], ['year', 'Jahr'], ['month', 'Monat']];
|
||||||
|
const chip = (k, l) => `
|
||||||
|
<button class="stats-tab" data-scope="${k}"
|
||||||
|
style="padding:7px 14px;border-radius:999px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;
|
||||||
|
border:1px solid var(--border);transition:all .15s;
|
||||||
|
background:${k === _view ? 'var(--accent)' : 'transparent'};
|
||||||
|
color:${k === _view ? '#fff' : 'var(--text2)'}">${l}</button>`;
|
||||||
|
const stepBtn = (dir, label, disabled) => `
|
||||||
|
<button data-step="${dir}" ${disabled ? 'disabled' : ''}
|
||||||
|
style="width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:transparent;
|
||||||
|
color:var(--text2);cursor:${disabled ? 'default' : 'pointer'};opacity:${disabled ? 0.35 : 1};
|
||||||
|
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
|
||||||
|
const stepper = _view === 'all' ? '' : `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
|
${stepBtn(-1, '‹', _atStart())}
|
||||||
|
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
|
||||||
|
${stepBtn(1, '›', _atCurrent())}
|
||||||
|
</div>`;
|
||||||
|
tabs.innerHTML = `<div style="display:flex;align-items:center;gap:6px;width:100%;flex-wrap:wrap">
|
||||||
|
${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper}
|
||||||
|
</div>`;
|
||||||
|
tabs.querySelectorAll('[data-scope]').forEach(b =>
|
||||||
|
b.addEventListener('click', () => { _view = b.dataset.scope; _renderTabs(); _load(); }));
|
||||||
|
tabs.querySelectorAll('[data-step]').forEach(b =>
|
||||||
|
b.addEventListener('click', () => { if (!b.disabled) { _step(+b.dataset.step); _renderTabs(); _load(); } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _load() {
|
||||||
|
const body = _overlay.querySelector('#stats-body');
|
||||||
|
if (!S.cfg.scrobbleUrl) {
|
||||||
|
body.innerHTML = `<div class="stats-empty" style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
|
||||||
|
Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein,
|
||||||
|
damit deine Wiedergaben aufgezeichnet werden.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auth = subsonicAuthParams();
|
||||||
|
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
|
||||||
|
|
||||||
|
// Only show "Lade…" on the very first open — on a period/tab switch keep the current content
|
||||||
|
// in place until the new data arrives, so the modal doesn't flash small→big (min-height also
|
||||||
|
// keeps its size stable). A per-request token guards against out-of-order responses.
|
||||||
|
if (!body.dataset.loaded) body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0">Lade…</div>`;
|
||||||
|
const token = String(Date.now());
|
||||||
|
body.dataset.req = token;
|
||||||
|
|
||||||
|
const { from, to, bucket } = _range();
|
||||||
|
const qs = `${auth}&from=${from}&to=${to}&tz=${_tzOffsetSec()}&bucket=${bucket}&limit=10`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
|
||||||
|
if (body.dataset.req !== token) return; // a newer request superseded this one
|
||||||
|
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
|
||||||
|
const d = await res.json();
|
||||||
|
if (body.dataset.req !== token) return;
|
||||||
|
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
|
||||||
|
_render(body, d);
|
||||||
|
body.dataset.loaded = '1';
|
||||||
|
} catch {
|
||||||
|
if (body.dataset.req === token) body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errBox(msg) {
|
||||||
|
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _render(body, d) {
|
||||||
|
if (!d.totalPlays) {
|
||||||
|
body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
|
||||||
|
Noch keine Wiedergaben in diesem Zeitraum.<br>Spiel ein paar Songs — sie erscheinen hier automatisch.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = [
|
||||||
|
_tiles(d),
|
||||||
|
_seriesChart(d.series, d.range.bucket),
|
||||||
|
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true,
|
||||||
|
a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
|
||||||
|
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId, false,
|
||||||
|
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
|
||||||
|
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
|
||||||
|
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
|
||||||
|
_topList('Top-Genres', d.topGenres, g => g.name, g => g.plays),
|
||||||
|
_hourClock(d.byHour),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tiles(d) {
|
||||||
|
const tile = (val, label) => `
|
||||||
|
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:14px 16px">
|
||||||
|
<div style="font-size:24px;font-weight:800;letter-spacing:-.5px">${val}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text2);margin-top:2px">${label}</div>
|
||||||
|
</div>`;
|
||||||
|
return `<div style="display:flex;gap:10px;margin-bottom:18px">
|
||||||
|
${tile(d.totalPlays.toLocaleString('de-DE'), 'Plays')}
|
||||||
|
${tile(_fmtTime(d.totalSeconds), 'Hörzeit')}
|
||||||
|
${tile((d.topArtists?.length || 0) >= 10 ? '10+' : (d.topArtists?.length || 0), 'Künstler')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple vertical bar chart of plays per bucket.
|
||||||
|
function _seriesChart(series, bucket) {
|
||||||
|
if (!series?.length) return '';
|
||||||
|
const max = Math.max(...series.map(s => s.plays), 1);
|
||||||
|
const fmtLabel = b => bucket === 'month'
|
||||||
|
? b.slice(5) + '.' + b.slice(2, 4) // "08.25"
|
||||||
|
: bucket === 'year' ? b : b.slice(8) + '.' + b.slice(5, 7); // "03.08"
|
||||||
|
const bars = series.map(s => `
|
||||||
|
<div title="${esc(s.bucket)}: ${s.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0">
|
||||||
|
<div style="width:100%;display:flex;align-items:flex-end;height:90px">
|
||||||
|
<div style="width:100%;background:var(--accent);border-radius:4px 4px 0 0;height:${Math.round((s.plays / max) * 100)}%;min-height:2px"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--text2);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:16px">${esc(fmtLabel(s.bucket))}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
return `${_h('Verlauf')}
|
||||||
|
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _topList(title, items, labelFn, valFn, idFn = null, circular = false, navFn = null) {
|
||||||
|
if (!items?.length) return '';
|
||||||
|
const max = Math.max(...items.map(valFn), 1);
|
||||||
|
const radius = circular ? '50%' : '6px';
|
||||||
|
const rows = items.map((it, i) => {
|
||||||
|
const val = valFn(it);
|
||||||
|
const cover = idFn ? coverUrl(idFn(it), 80) : null;
|
||||||
|
const thumb = idFn
|
||||||
|
? `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
|
||||||
|
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
const nav = navFn ? navFn(it) : null;
|
||||||
|
const navAttrs = nav
|
||||||
|
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`
|
||||||
|
: '';
|
||||||
|
return `<div ${navAttrs} style="display:flex;align-items:center;gap:10px;padding:6px 0${nav ? ';cursor:pointer' : ''}">
|
||||||
|
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
|
||||||
|
${thumb}
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(labelFn(it))}</div>
|
||||||
|
<div style="height:5px;background:var(--surface2);border-radius:3px;margin-top:4px;overflow:hidden">
|
||||||
|
<div style="height:100%;background:var(--accent);width:${Math.round((val / max) * 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--text2);font-weight:600;min-width:34px;text-align:right">${val}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `${_h(title)}<div style="margin-bottom:22px">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hourClock(byHour) {
|
||||||
|
if (!byHour?.length) return '';
|
||||||
|
const max = Math.max(...byHour.map(h => h.plays), 1);
|
||||||
|
const bars = byHour.map(h => `
|
||||||
|
<div title="${h.hour}:00 — ${h.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0">
|
||||||
|
<div style="width:100%;display:flex;align-items:flex-end;height:56px">
|
||||||
|
<div style="width:100%;background:var(--accent);opacity:${h.plays ? 1 : 0.15};border-radius:3px 3px 0 0;height:${Math.round((h.plays / max) * 100)}%;min-height:2px"></div>
|
||||||
|
</div>
|
||||||
|
${h.hour % 6 === 0 ? `<div style="font-size:9px;color:var(--text2)">${h.hour}</div>` : '<div style="height:11px"></div>'}
|
||||||
|
</div>`).join('');
|
||||||
|
return `${_h('Hör-Uhr (Stunde)')}
|
||||||
|
<div style="display:flex;gap:2px;align-items:flex-end;margin:6px 0 8px">${bars}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _h(t) {
|
||||||
|
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 8px">${esc(t)}</div>`;
|
||||||
|
}
|
||||||
+3
-1
@@ -36,7 +36,9 @@ export function md5(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function esc(s) {
|
export function esc(s) {
|
||||||
return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
// Coerce to string first — callers sometimes pass numbers (e.g. a Deezer artist id), which
|
||||||
|
// would throw on .replace. null/undefined → ''.
|
||||||
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDur(s) {
|
export function formatDur(s) {
|
||||||
|
|||||||
+126
-67
@@ -1,6 +1,7 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc, toast, fmtCount } from '../utils.js';
|
import { esc, toast, fmtCount } from '../utils.js';
|
||||||
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, deezerArtistImage, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap } from '../api.js';
|
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||||||
|
import { openArtistPin } from '../ui/artistPin.js';
|
||||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
|
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
|
||||||
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
|
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
|
||||||
import { showCtxMenu, openDlModal } from '../ui/ui.js';
|
import { showCtxMenu, openDlModal } from '../ui/ui.js';
|
||||||
@@ -87,16 +88,32 @@ export async function loadArtistDetail(artist) {
|
|||||||
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
|
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
const hasND = !!artist.id;
|
const hasND = !!artist.id;
|
||||||
const knownItunes = artist.itunesArtistId || null;
|
// Manual pin (same-name fix): use the pinned Deezer artist for id/fans/image instead of guessing.
|
||||||
const knownFans = artist.itunesFanCount ?? null;
|
const _pin = hasND ? getArtistOverride(artist.id) : null;
|
||||||
if (knownFans) _showFans(knownFans); // from the global-search card — show immediately
|
const knownDeezer = _pin?.deezerArtistId || artist.deezerArtistId || null;
|
||||||
|
const knownFans = _pin?.fanCount ?? artist.deezerFanCount ?? null;
|
||||||
|
if (knownFans) _showFans(knownFans); // from the global-search card / pin — show immediately
|
||||||
|
|
||||||
|
// "Falscher Künstler?" pin button (local artists only) — top-right of the banner.
|
||||||
|
if (hasND && hero) {
|
||||||
|
hero.querySelector('.artist-pin-btn')?.remove();
|
||||||
|
const pinBtn = document.createElement('button');
|
||||||
|
pinBtn.className = 'artist-pin-btn';
|
||||||
|
pinBtn.textContent = '⚙';
|
||||||
|
pinBtn.title = _pin ? 'Gepinnt — anderen Deezer-Künstler wählen' : 'Falscher Künstler? Richtigen Deezer-Künstler wählen';
|
||||||
|
// Minimal: a small, subtle icon-only chip in the corner (not a text banner).
|
||||||
|
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
|
||||||
|
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
|
||||||
|
hero.appendChild(pinBtn);
|
||||||
|
}
|
||||||
|
|
||||||
// Fire all requests immediately so they run in parallel.
|
// Fire all requests immediately so they run in parallel.
|
||||||
const imgP = hasND ? getArtistImage(artist.id) : Promise.resolve(null);
|
const imgP = _pin?.image ? Promise.resolve(_pin.image)
|
||||||
|
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
|
||||||
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
|
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
|
||||||
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
|
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
|
||||||
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
|
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
|
||||||
const iArtistP = knownItunes ? Promise.resolve([{ artistId: knownItunes }]) : itunesSearch(artist.name, 'musicArtist', 5);
|
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
|
||||||
const eventsP = fetchEvents(artist.name);
|
const eventsP = fetchEvents(artist.name);
|
||||||
|
|
||||||
// Phase 1 — hero: only wait for albumP (fast local Navidrome call, no external API).
|
// Phase 1 — hero: only wait for albumP (fast local Navidrome call, no external API).
|
||||||
@@ -138,18 +155,24 @@ export async function loadArtistDetail(artist) {
|
|||||||
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// Phase 2 — content (may be slow: Last.fm bio, iTunes, events)
|
// Phase 2 — content (may be slow: Last.fm bio, Deezer, events)
|
||||||
const [infoRes, topRes, iArtistRes, eventsRes] = await Promise.allSettled([infoP, topP, iArtistP, eventsP]);
|
const [infoRes, topRes, iArtistRes, eventsRes] = await Promise.allSettled([infoP, topP, iArtistP, eventsP]);
|
||||||
|
|
||||||
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
|
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
|
||||||
|
const _normName = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
const _queryWords = _normWords(artist.name);
|
const _queryWords = _normWords(artist.name);
|
||||||
|
const _qNorm = _normName(artist.name);
|
||||||
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
|
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
|
||||||
// Name-matching candidates, already ordered by fanCount (itunesSearch sorts musicArtist by nb_fan).
|
// Prefer EXACT normalized-name matches. "Flay!" must not resolve to the far more popular "K.FLAY"
|
||||||
const nameMatches = iArtistResults.filter(r => {
|
// (whose name merely contains the word "flay") — exact "flay" == "flay" wins over "kflay". Only fall
|
||||||
|
// back to the looser word-subset match when there's no exact match. Both lists are fan-sorted already.
|
||||||
|
const exactMatches = iArtistResults.filter(r => _normName(r.artistName) === _qNorm);
|
||||||
|
const looseMatches = iArtistResults.filter(r => {
|
||||||
if (_queryWords.size === 0) return true;
|
if (_queryWords.size === 0) return true;
|
||||||
const rw = _normWords(r.artistName);
|
const rw = _normWords(r.artistName);
|
||||||
return [..._queryWords].every(w => rw.has(w));
|
return [..._queryWords].every(w => rw.has(w));
|
||||||
});
|
});
|
||||||
|
const nameMatches = exactMatches.length ? exactMatches : looseMatches;
|
||||||
// Default pick = most popular name match (fixes "5× Joji": Deezer's order buries the real one).
|
// Default pick = most popular name match (fixes "5× Joji": Deezer's order buries the real one).
|
||||||
let iArtistMatch = nameMatches[0] || null;
|
let iArtistMatch = nameMatches[0] || null;
|
||||||
|
|
||||||
@@ -157,7 +180,7 @@ export async function loadArtistDetail(artist) {
|
|||||||
// artist we own songs for, prefer the Deezer candidate whose top tracks actually overlap our
|
// artist we own songs for, prefer the Deezer candidate whose top tracks actually overlap our
|
||||||
// library — each artist has different songs, so this nails the right person. Only runs on a real
|
// library — each artist has different songs, so this nails the right person. Only runs on a real
|
||||||
// collision (≥2 name matches) and costs a few calls just for those.
|
// collision (≥2 name matches) and costs a few calls just for those.
|
||||||
if (!knownItunes && hasND && nameMatches.length > 1) {
|
if (!knownDeezer && hasND && nameMatches.length > 1) {
|
||||||
const _nt = s => (s || '').toLowerCase().replace(/\([^)]*\)/g, '').replace(/[^a-z0-9]/g, '');
|
const _nt = s => (s || '').toLowerCase().replace(/\([^)]*\)/g, '').replace(/[^a-z0-9]/g, '');
|
||||||
const refTitles = new Set(
|
const refTitles = new Set(
|
||||||
((topRes.status === 'fulfilled' ? topRes.value?.topSongs?.song : []) || [])
|
((topRes.status === 'fulfilled' ? topRes.value?.topSongs?.song : []) || [])
|
||||||
@@ -166,7 +189,7 @@ export async function loadArtistDetail(artist) {
|
|||||||
if (refTitles.size) {
|
if (refTitles.size) {
|
||||||
const cands = nameMatches.slice(0, 3);
|
const cands = nameMatches.slice(0, 3);
|
||||||
const scored = await Promise.all(cands.map(async c => {
|
const scored = await Promise.all(cands.map(async c => {
|
||||||
const tt = await itunesLookupArtistSongs(c.artistId, 15).catch(() => []);
|
const tt = await deezerLookupArtistSongs(c.artistId, 15).catch(() => []);
|
||||||
const overlap = tt.reduce((n, t) => n + (refTitles.has(_nt(t.trackName)) ? 1 : 0), 0);
|
const overlap = tt.reduce((n, t) => n + (refTitles.has(_nt(t.trackName)) ? 1 : 0), 0);
|
||||||
return { c, overlap };
|
return { c, overlap };
|
||||||
}));
|
}));
|
||||||
@@ -175,31 +198,26 @@ export async function loadArtistDetail(artist) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null;
|
const deezerArtistId = knownDeezer ?? iArtistMatch?.artistId ?? null;
|
||||||
if (!knownFans) _showFans(iArtistMatch?.fanCount); // local artist: show the matched Deezer artist's fans
|
if (!knownFans) _showFans(iArtistMatch?.fanCount); // local artist: show the matched Deezer artist's fans
|
||||||
|
|
||||||
// Artist photo: Navidrome (from its metadata agents) first; if it has none, fall back to the
|
// Non-local (Deezer) artist has no Navidrome photo — fetch the Deezer artist picture for the hero.
|
||||||
// matched Deezer artist's picture. Covers non-local Deezer artists AND niche local artists
|
if (!hasND && deezerArtistId && pfpWrap) {
|
||||||
// (e.g. N!GHT) that Navidrome's agents have no art for — the hero was blank for those before.
|
const dzImg = iArtistMatch?.artworkUrl100 || await deezerArtistImage(deezerArtistId);
|
||||||
if (itunesArtistId && pfpWrap) {
|
if (dzImg) {
|
||||||
const ndImg = await imgP.catch(() => null);
|
const pfpImg = document.createElement('img');
|
||||||
if (!ndImg) {
|
pfpImg.className = 'artist-hero-pfp';
|
||||||
const dzImg = iArtistMatch?.artworkUrl100 || await deezerArtistImage(itunesArtistId);
|
pfpImg.onload = pfpImg.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${dzImg}')`; _revealHero(); };
|
||||||
if (dzImg) {
|
pfpImg.src = dzImg;
|
||||||
const pfpImg = document.createElement('img');
|
pfpWrap.prepend(pfpImg);
|
||||||
pfpImg.className = 'artist-hero-pfp';
|
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
||||||
pfpImg.onload = pfpImg.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${dzImg}')`; _revealHero(); };
|
|
||||||
pfpImg.src = dzImg;
|
|
||||||
pfpWrap.prepend(pfpImg);
|
|
||||||
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [iAlbumsRes, iTracksRes] = itunesArtistId
|
const [iAlbumsRes, iTracksRes] = deezerArtistId
|
||||||
? await Promise.allSettled([
|
? await Promise.allSettled([
|
||||||
itunesLookupArtistAlbums(itunesArtistId),
|
deezerLookupArtistAlbums(deezerArtistId, 100, artist.name),
|
||||||
itunesLookupArtistSongs(itunesArtistId, 15),
|
deezerLookupArtistSongs(deezerArtistId, 15),
|
||||||
])
|
])
|
||||||
: [{ status: 'rejected' }, { status: 'rejected' }];
|
: [{ status: 'rejected' }, { status: 'rejected' }];
|
||||||
|
|
||||||
@@ -213,18 +231,46 @@ export async function loadArtistDetail(artist) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Similar artists ──
|
// ── Similar artists (with profile pictures) ──
|
||||||
const similar = info?.similarArtist || [];
|
// When pinned, use Deezer "related" for the pinned artist — Last.fm's list is keyed to the
|
||||||
if(similar.length && simEl) {
|
// (possibly merged/wrong) Navidrome artist, so it goes "weird". Deezer related carry real photos.
|
||||||
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
if (simEl) {
|
||||||
<div class="similar-chips">${similar.map(a =>
|
let simList;
|
||||||
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
|
if (_pin?.deezerArtistId) {
|
||||||
${esc(a.name)}</span>`).join('')}</div>`;
|
const rel = await deezerRelatedArtists(_pin.deezerArtistId, 12);
|
||||||
|
simList = rel.map(r => ({
|
||||||
|
name: r.artistName, img: r.artworkUrl100 || '',
|
||||||
|
nav: { id: null, name: r.artistName, deezerArtistId: r.artistId, deezerFanCount: r.fanCount ?? null },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
simList = (info?.similarArtist || []).map(a => ({
|
||||||
|
name: a.name, img: coverUrl(a.coverArt || a.id, 160) || '',
|
||||||
|
nav: { id: a.id || a.name, name: a.name },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (simList.length) {
|
||||||
|
const _navAttr = n => esc(JSON.stringify(n)).replace(/"/g, '"');
|
||||||
|
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
||||||
|
<div class="scroll-row">${simList.map(a => `
|
||||||
|
<div class="artist-card" style="cursor:pointer;width:88px" onclick='navigate("artist-detail", ${_navAttr(a.nav)})'>
|
||||||
|
<div class="card-art" style="width:88px;height:88px;border-radius:50%;overflow:hidden">
|
||||||
|
${a.img ? `<img src="${esc(a.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">` : `<div class="no-art">🎤</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="card-title" style="text-align:center;font-size:12px;margin-top:6px">${esc(a.name)}</div>
|
||||||
|
</div>`).join('')}</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Top Songs ──
|
// ── Top Songs ──
|
||||||
const ndAlbums = _ndArtist?.album || [];
|
const ndAlbums = _ndArtist?.album || [];
|
||||||
const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
|
// Navidrome can hold the same track twice (e.g. downloaded as a single AND as an album track),
|
||||||
|
// so getTopSongs returns duplicate rows ("PIXELATED KISSES" x2). Collapse by title+album for display.
|
||||||
|
const _rawTop = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
|
||||||
|
const _seenTop = new Set();
|
||||||
|
const topSongs = _rawTop.filter(s => {
|
||||||
|
const k = `${s.title}|${s.album}`.toLowerCase().replace(/[^a-z0-9|]/g, '');
|
||||||
|
return _seenTop.has(k) ? false : (_seenTop.add(k), true);
|
||||||
|
});
|
||||||
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
|
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
|
||||||
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];
|
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];
|
||||||
|
|
||||||
@@ -291,14 +337,17 @@ export async function loadArtistDetail(artist) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _normAlbum(s) {
|
function _normAlbum(s) {
|
||||||
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
// Keep Unicode letters/numbers (not just a-z0-9) — otherwise every Japanese/CJK title strips to an
|
||||||
|
// empty string, so all of an artist's JP singles collapse into one entry and even "match" each
|
||||||
|
// other (that hid inabakumori's whole singles discography behind one album).
|
||||||
|
return (s || '').toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
||||||
el.innerHTML = '<div class="spinner"></div>';
|
el.innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
// Match each Deezer album to a Navidrome album by normalized name.
|
// Match each Deezer album to a Navidrome album by normalized name.
|
||||||
const entries = iAlbums.map(ia => {
|
const allEntries = iAlbums.map(ia => {
|
||||||
const iName = _normAlbum(ia.collectionName);
|
const iName = _normAlbum(ia.collectionName);
|
||||||
const match = ndAlbums.find(a => {
|
const match = ndAlbums.find(a => {
|
||||||
const n = _normAlbum(a.name);
|
const n = _normAlbum(a.name);
|
||||||
@@ -306,6 +355,16 @@ async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
|||||||
});
|
});
|
||||||
return { ia, match };
|
return { ia, match };
|
||||||
});
|
});
|
||||||
|
// Deezer returns the same album several times (single/EP/album/regional/explicit variants), which
|
||||||
|
// rendered one owned album 3× ("PIXELATED KISSES"). Collapse to one entry per owned Navidrome album
|
||||||
|
// and one per not-owned title.
|
||||||
|
const entries = [];
|
||||||
|
const seenNd = new Set(), seenName = new Set();
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (e.match) { if (seenNd.has(e.match.id)) continue; seenNd.add(e.match.id); }
|
||||||
|
else { const k = _normAlbum(e.ia.collectionName); if (seenName.has(k)) continue; seenName.add(k); }
|
||||||
|
entries.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Deezer's /artist/{id}/albums list omits nb_tracks, so the total is unknown here — which made every
|
// Deezer's /artist/{id}/albums list omits nb_tracks, so the total is unknown here — which made every
|
||||||
// owned album look "incomplete" (e.g. "Moonlight 1 / 0"). Resolve the real track count from the
|
// owned album look "incomplete" (e.g. "Moonlight 1 / 0"). Resolve the real track count from the
|
||||||
@@ -314,7 +373,7 @@ async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
|||||||
await Promise.all(entries
|
await Promise.all(entries
|
||||||
.filter(e => e.match && !(e.ia.trackCount > 0) && e.ia.collectionId)
|
.filter(e => e.match && !(e.ia.trackCount > 0) && e.ia.collectionId)
|
||||||
.map(async e => {
|
.map(async e => {
|
||||||
const tracks = await itunesLookupAlbum(e.ia.collectionId).catch(() => []);
|
const tracks = await deezerLookupAlbum(e.ia.collectionId).catch(() => []);
|
||||||
if (tracks.length) e.ia = { ...e.ia, trackCount: tracks.length };
|
if (tracks.length) e.ia = { ...e.ia, trackCount: tracks.length };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -324,11 +383,11 @@ async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
|||||||
if(!match) { notDl.push(ia); continue; }
|
if(!match) { notDl.push(ia); continue; }
|
||||||
const have = match.songCount || 0;
|
const have = match.songCount || 0;
|
||||||
const total = ia.trackCount || 0;
|
const total = ia.trackCount || 0;
|
||||||
if(total > have) { partial.push({ nd: match, itunes: ia, have, total }); usedNdIds.add(match.id); }
|
if(total > have) { partial.push({ nd: match, deezer: ia, have, total }); usedNdIds.add(match.id); }
|
||||||
else { downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id); }
|
else { downloaded.push({ nd: match, deezer: ia }); usedNdIds.add(match.id); }
|
||||||
}
|
}
|
||||||
for(const na of ndAlbums) {
|
for(const na of ndAlbums) {
|
||||||
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
|
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, deezer: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
@@ -352,20 +411,20 @@ async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
|||||||
}
|
}
|
||||||
if(partial.length) {
|
if(partial.length) {
|
||||||
const g = addGrid('Unvollständig', partial.length);
|
const g = addGrid('Unvollständig', partial.length);
|
||||||
partial.forEach(({ nd, itunes, have, total }) => {
|
partial.forEach(({ nd, deezer, have, total }) => {
|
||||||
const art = itunes?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
|
const art = deezer?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
|
||||||
const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿');
|
const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿');
|
||||||
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); };
|
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); };
|
||||||
c.querySelector('.card-info').onclick = () => itunes ? navigate('partial-album-detail', { nd, itunes }) : navigate('album-detail', { ...nd, _fromArtist: artist });
|
c.querySelector('.card-info').onclick = () => deezer ? navigate('partial-album-detail', { nd, deezer }) : navigate('album-detail', { ...nd, _fromArtist: artist });
|
||||||
const artEl = c.querySelector('.card-art');
|
const artEl = c.querySelector('.card-art');
|
||||||
const badge = document.createElement('div');
|
const badge = document.createElement('div');
|
||||||
badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`;
|
badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`;
|
||||||
artEl.appendChild(badge);
|
artEl.appendChild(badge);
|
||||||
if(itunes) {
|
if(deezer) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden';
|
btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden';
|
||||||
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
||||||
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId }); };
|
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId }); };
|
||||||
artEl.appendChild(btn);
|
artEl.appendChild(btn);
|
||||||
}
|
}
|
||||||
g.appendChild(c);
|
g.appendChild(c);
|
||||||
@@ -376,12 +435,12 @@ async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
|||||||
notDl.forEach(ia => {
|
notDl.forEach(ia => {
|
||||||
const art = ia.artworkUrl100?.replace('100x100','300x300');
|
const art = ia.artworkUrl100?.replace('100x100','300x300');
|
||||||
const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿');
|
const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿');
|
||||||
c.onclick = () => navigate('itunes-album-detail', ia);
|
c.onclick = () => navigate('deezer-album-detail', ia);
|
||||||
const artEl = c.querySelector('.card-art');
|
const artEl = c.querySelector('.card-art');
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'card-dl-btn'; btn.title = 'Album laden';
|
btn.className = 'card-dl-btn'; btn.title = 'Album laden';
|
||||||
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
||||||
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, itunesData: ia, collectionId: ia.collectionId }); };
|
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, deezerData: ia, collectionId: ia.collectionId }); };
|
||||||
artEl.appendChild(btn);
|
artEl.appendChild(btn);
|
||||||
g.appendChild(c);
|
g.appendChild(c);
|
||||||
});
|
});
|
||||||
@@ -420,32 +479,32 @@ export async function loadArtistAllSongs({ albums, artist }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadItunesAlbumDetail(album) {
|
export async function loadDeezerAlbumDetail(album) {
|
||||||
document.getElementById('itunes-album-back-btn').onclick = () => _navGoBack();
|
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
|
||||||
document.getElementById('itunes-album-detail-name').textContent = album.collectionName || '—';
|
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
|
||||||
document.getElementById('itunes-album-detail-artist').textContent = album.artistName || '';
|
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || '';
|
||||||
const cw = document.getElementById('itunes-album-cover-wrap');
|
const cw = document.getElementById('deezer-album-cover-wrap');
|
||||||
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
|
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
|
||||||
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||||||
const dlBtn = document.getElementById('itunes-album-dl-btn');
|
const dlBtn = document.getElementById('deezer-album-dl-btn');
|
||||||
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, itunesData: album, collectionId: album.collectionId });
|
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, deezerData: album, collectionId: album.collectionId });
|
||||||
const sl = document.getElementById('itunes-album-songs');
|
const sl = document.getElementById('deezer-album-songs');
|
||||||
sl.innerHTML = '<div class="spinner"></div>';
|
sl.innerHTML = '<div class="spinner"></div>';
|
||||||
try {
|
try {
|
||||||
const tracks = await itunesLookupAlbum(album.collectionId);
|
const tracks = await deezerLookupAlbum(album.collectionId);
|
||||||
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
|
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
|
||||||
document.getElementById('itunes-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
|
document.getElementById('deezer-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
|
||||||
sl.innerHTML = '';
|
sl.innerHTML = '';
|
||||||
if(!tracks.length) { sl.innerHTML = '<div style="padding:16px;color:var(--text3)">Keine Tracks gefunden</div>'; return; }
|
if(!tracks.length) { sl.innerHTML = '<div style="padding:16px;color:var(--text3)">Keine Tracks gefunden</div>'; return; }
|
||||||
tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks)));
|
tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks)));
|
||||||
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPartialAlbumDetail({ nd, itunes }) {
|
export async function loadPartialAlbumDetail({ nd, deezer }) {
|
||||||
document.getElementById('partial-album-back-btn').onclick = () => _navGoBack();
|
document.getElementById('partial-album-back-btn').onclick = () => _navGoBack();
|
||||||
document.getElementById('partial-album-detail-name').textContent = nd.name || '—';
|
document.getElementById('partial-album-detail-name').textContent = nd.name || '—';
|
||||||
document.getElementById('partial-album-detail-artist').textContent = nd.artist || itunes?.artistName || '';
|
document.getElementById('partial-album-detail-artist').textContent = nd.artist || deezer?.artistName || '';
|
||||||
const art = (itunes?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600')
|
const art = (deezer?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600')
|
||||||
|| (nd.coverArt ? coverUrl(nd.coverArt, 512) : '');
|
|| (nd.coverArt ? coverUrl(nd.coverArt, 512) : '');
|
||||||
const cw = document.getElementById('partial-album-cover-wrap');
|
const cw = document.getElementById('partial-album-cover-wrap');
|
||||||
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||||||
@@ -458,7 +517,7 @@ export async function loadPartialAlbumDetail({ nd, itunes }) {
|
|||||||
try {
|
try {
|
||||||
const [ndRes, iTracks] = await Promise.all([
|
const [ndRes, iTracks] = await Promise.all([
|
||||||
api('getAlbum', `&id=${nd.id}`),
|
api('getAlbum', `&id=${nd.id}`),
|
||||||
itunes?.collectionId ? itunesLookupAlbum(itunes.collectionId) : Promise.resolve([]),
|
deezer?.collectionId ? deezerLookupAlbum(deezer.collectionId) : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
const ndSongs = (ndRes.album?.song || []).sort((a,b) => (a.track||999)-(b.track||999));
|
const ndSongs = (ndRes.album?.song || []).sort((a,b) => (a.track||999)-(b.track||999));
|
||||||
|
|
||||||
@@ -474,9 +533,9 @@ export async function loadPartialAlbumDetail({ nd, itunes }) {
|
|||||||
if(ndSongs.length) playQueue(ndSongs, 0);
|
if(ndSongs.length) playQueue(ndSongs, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download button offers missing via iTunes
|
// Download button offers missing via Deezer
|
||||||
document.getElementById('partial-album-dl-btn').onclick = () => {
|
document.getElementById('partial-album-dl-btn').onclick = () => {
|
||||||
if(itunes) openDlModal({ artistName: itunes.artistName, trackName: itunes.collectionName, collectionName: itunes.collectionName, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId });
|
if(deezer) openDlModal({ artistName: deezer.artistName, trackName: deezer.collectionName, collectionName: deezer.collectionName, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Section: downloaded
|
// Section: downloaded
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc } from '../utils.js';
|
import { esc } from '../utils.js';
|
||||||
import { itunesSearch, itunesTopCharts, itunesTopSongs } from '../api.js';
|
import { deezerSearch, deezerTopCharts, deezerTopSongs } from '../api.js';
|
||||||
import { makeCard, makeGlobalTrackItem } from '../ui/components.js';
|
import { makeCard, makeGlobalTrackItem } from '../ui/components.js';
|
||||||
import { playPreview } from '../player/player.js';
|
import { playPreview } from '../player/player.js';
|
||||||
import { openDlModal } from '../ui/ui.js';
|
import { openDlModal } from '../ui/ui.js';
|
||||||
// Circular — safe (called inside event handlers)
|
// Circular — safe (called inside event handlers)
|
||||||
import { loadItunesAlbum, searchAndPreview } from './search.js';
|
import { loadDeezerAlbum, searchAndPreview } from './search.js';
|
||||||
|
|
||||||
// ─── NEW RELEASES ───
|
// ─── NEW RELEASES ───
|
||||||
export async function loadNewReleases() {
|
export async function loadNewReleases() {
|
||||||
@@ -13,12 +13,12 @@ export async function loadNewReleases() {
|
|||||||
const songEl = document.getElementById('nr-songs');
|
const songEl = document.getElementById('nr-songs');
|
||||||
albumEl.innerHTML = '<div class="spinner"></div>'; songEl.innerHTML = '';
|
albumEl.innerHTML = '<div class="spinner"></div>'; songEl.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
const [albums, songs] = await Promise.all([itunesTopCharts(), itunesTopSongs()]);
|
const [albums, songs] = await Promise.all([deezerTopCharts(), deezerTopSongs()]);
|
||||||
albumEl.innerHTML = '';
|
albumEl.innerHTML = '';
|
||||||
albums.slice(0,30).forEach(a => {
|
albums.slice(0,30).forEach(a => {
|
||||||
const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl;
|
const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl;
|
||||||
const c = makeCard(a.name, a.artistName, art, '💿');
|
const c = makeCard(a.name, a.artistName, art, '💿');
|
||||||
c.onclick = () => itunesSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadItunesAlbum(res[0]); });
|
c.onclick = () => deezerSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadDeezerAlbum(res[0]); });
|
||||||
albumEl.appendChild(c);
|
albumEl.appendChild(c);
|
||||||
});
|
});
|
||||||
songs.slice(0,40).forEach((s, i) => {
|
songs.slice(0,40).forEach((s, i) => {
|
||||||
@@ -36,7 +36,7 @@ export async function loadNewReleases() {
|
|||||||
<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>
|
<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>
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
el.onclick = () => { itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
|
el.onclick = () => { deezerSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
|
||||||
songEl.appendChild(el);
|
songEl.appendChild(el);
|
||||||
});
|
});
|
||||||
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||||
@@ -62,7 +62,7 @@ export async function loadRecommendations() {
|
|||||||
if(S.cfg.lastfmKey) {
|
if(S.cfg.lastfmKey) {
|
||||||
await _loadLastfmRecommendations(el, artist, title, html);
|
await _loadLastfmRecommendations(el, artist, title, html);
|
||||||
} else {
|
} else {
|
||||||
await _loadItunesFallbackRecommendations(el, artist, title, html);
|
await _loadDeezerFallbackRecommendations(el, artist, title, html);
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
|
note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
|
||||||
note.innerHTML = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
|
note.innerHTML = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
|
||||||
@@ -123,11 +123,11 @@ async function _loadLastfmRecommendations(el, artist, title, html) {
|
|||||||
});
|
});
|
||||||
sec.appendChild(list); el.appendChild(sec);
|
sec.appendChild(list); el.appendChild(sec);
|
||||||
}
|
}
|
||||||
} catch { await _loadItunesFallbackRecommendations(el, artist, title, html); }
|
} catch { await _loadDeezerFallbackRecommendations(el, artist, title, html); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _loadItunesFallbackRecommendations(el, artist, title, html) {
|
async function _loadDeezerFallbackRecommendations(el, artist, title, html) {
|
||||||
const results = await itunesSearch(artist, 'musicTrack', 20);
|
const results = await deezerSearch(artist, 'musicTrack', 20);
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
const sec = document.createElement('div');
|
const sec = document.createElement('div');
|
||||||
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
|
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
|
||||||
|
|||||||
+39
-6
@@ -5,9 +5,10 @@ import { loadSpotifyImporter } from './spotify.js';
|
|||||||
import { loadSoundCloudImporter } from './soundcloud.js';
|
import { loadSoundCloudImporter } from './soundcloud.js';
|
||||||
// Circular — safe (all calls happen inside function bodies)
|
// Circular — safe (all calls happen inside function bodies)
|
||||||
import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } from './home.js';
|
import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } from './home.js';
|
||||||
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadItunesAlbumDetail, loadPartialAlbumDetail } from './detail.js';
|
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js';
|
||||||
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
|
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
|
||||||
import { loadNewReleases, loadRecommendations } from './discover.js';
|
import { loadNewReleases, loadRecommendations } from './discover.js';
|
||||||
|
import { onHeaderSearch } from './search.js';
|
||||||
import { invalidateApiCache } from '../api.js';
|
import { invalidateApiCache } from '../api.js';
|
||||||
|
|
||||||
// ─── NAVIGATION HISTORY ───
|
// ─── NAVIGATION HISTORY ───
|
||||||
@@ -59,15 +60,41 @@ export function refreshCurrentView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(page, data=null) {
|
export function navigate(page, data=null) {
|
||||||
|
// Scroll + search memory: remember where each history entry was scrolled AND any open local
|
||||||
|
// (header) search, so Back/Forward returns you to the same spot with the same query. The global
|
||||||
|
// search page keeps its query/results on its own (its DOM persists). A genuine new navigation
|
||||||
|
// still starts at the top with a clean header search. On a genuine nav we snapshot the OUTGOING
|
||||||
|
// view here (cursor still points at it); on Back/Forward the snapshot is taken in _navOnPopState.
|
||||||
|
const _main = document.getElementById('main-content');
|
||||||
|
const _hsInput = document.getElementById('header-search-input');
|
||||||
|
S._scrollByIdx = S._scrollByIdx || {};
|
||||||
|
S._hsByIdx = S._hsByIdx || {};
|
||||||
|
if(!S._navJumping) {
|
||||||
|
if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop;
|
||||||
|
S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : '';
|
||||||
|
}
|
||||||
closeHeaderSearch();
|
closeHeaderSearch();
|
||||||
|
|
||||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
||||||
const cls = ['discover','new-releases','recommendations','spotify-importer'].includes(page) ? 'active-blue' : 'active';
|
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
|
||||||
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
||||||
const pageEl = document.getElementById(`page-${page}`);
|
const pageEl = document.getElementById(`page-${page}`);
|
||||||
if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
|
if(pageEl) {
|
||||||
|
pageEl.classList.add('active');
|
||||||
|
if(S._navJumping) {
|
||||||
|
// Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and
|
||||||
|
// re-open the local (header) search if one was active here.
|
||||||
|
const y = S._scrollByIdx[S._navCursor] || 0;
|
||||||
|
if(_main) requestAnimationFrame(() => { _main.scrollTop = y; });
|
||||||
|
const hsq = S._hsByIdx[S._navCursor];
|
||||||
|
if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); }
|
||||||
|
} else if(_main) {
|
||||||
|
_main.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
if(['discover','new-releases','recommendations','random','spotify-importer'].includes(page)) switchNavMode('discover');
|
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
|
||||||
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
|
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
|
||||||
|
|
||||||
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
|
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
|
||||||
@@ -79,13 +106,12 @@ export function navigate(page, data=null) {
|
|||||||
if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
|
if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
|
||||||
if(page==='album-detail' && data) loadAlbumDetail(data);
|
if(page==='album-detail' && data) loadAlbumDetail(data);
|
||||||
if(page==='genre-detail' && data) loadGenreDetail(data);
|
if(page==='genre-detail' && data) loadGenreDetail(data);
|
||||||
if(page==='itunes-album-detail' && data) loadItunesAlbumDetail(data);
|
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
|
||||||
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
|
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
|
||||||
if(page==='new-releases') loadNewReleases();
|
if(page==='new-releases') loadNewReleases();
|
||||||
if(page==='playlists') loadPlaylists();
|
if(page==='playlists') loadPlaylists();
|
||||||
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
||||||
if(page==='recommendations') loadRecommendations();
|
if(page==='recommendations') loadRecommendations();
|
||||||
if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
|
|
||||||
if(page==='audiobooks') {
|
if(page==='audiobooks') {
|
||||||
import('./abs.js').then(m => {
|
import('./abs.js').then(m => {
|
||||||
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
|
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
|
||||||
@@ -134,6 +160,13 @@ export function _navOnPopState(state) {
|
|||||||
}
|
}
|
||||||
const idx = (state && typeof state.nsIdx === 'number') ? state.nsIdx : 0;
|
const idx = (state && typeof state.nsIdx === 'number') ? state.nsIdx : 0;
|
||||||
if(idx < 0 || idx >= S._navStack.length) return;
|
if(idx < 0 || idx >= S._navStack.length) return;
|
||||||
|
// Snapshot the OUTGOING view's scroll + any open local search before we move the cursor.
|
||||||
|
const _main = document.getElementById('main-content');
|
||||||
|
const _hsInput = document.getElementById('header-search-input');
|
||||||
|
S._scrollByIdx = S._scrollByIdx || {};
|
||||||
|
S._hsByIdx = S._hsByIdx || {};
|
||||||
|
if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop;
|
||||||
|
S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : '';
|
||||||
S._navCursor = idx;
|
S._navCursor = idx;
|
||||||
const entry = S._navStack[idx];
|
const entry = S._navStack[idx];
|
||||||
_navSave();
|
_navSave();
|
||||||
|
|||||||
+23
-19
@@ -1,6 +1,6 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc, toast, fmtCount } from '../utils.js';
|
import { esc, toast, fmtCount } from '../utils.js';
|
||||||
import { api, itunesSearch, itunesLastError, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
|
import { api, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById, getArtistOverride } from '../api.js';
|
||||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
|
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
|
||||||
import { playPreview } from '../player/player.js';
|
import { playPreview } from '../player/player.js';
|
||||||
import { openDlModal } from '../ui/ui.js';
|
import { openDlModal } from '../ui/ui.js';
|
||||||
@@ -64,12 +64,16 @@ export async function doLocalSearch(q, el) {
|
|||||||
c.classList.add('artist-card');
|
c.classList.add('artist-card');
|
||||||
c.onclick = () => navigate('artist-detail', a);
|
c.onclick = () => navigate('artist-detail', a);
|
||||||
g.appendChild(c);
|
g.appendChild(c);
|
||||||
getArtistImage(a.id).then(img => {
|
const _setArt = img => {
|
||||||
if(!img) return;
|
if(!img) return;
|
||||||
const artEl = c.querySelector('.card-art');
|
const artEl = c.querySelector('.card-art');
|
||||||
if(!artEl) return;
|
if(!artEl) return;
|
||||||
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
|
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
|
||||||
});
|
};
|
||||||
|
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image is the wrong person).
|
||||||
|
const _pin = getArtistOverride(a.id);
|
||||||
|
if (_pin?.image) _setArt(_pin.image);
|
||||||
|
else getArtistImage(a.id).then(_setArt);
|
||||||
});
|
});
|
||||||
s.appendChild(g); el.appendChild(s);
|
s.appendChild(g); el.appendChild(s);
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ export function onHeaderSearch(val) {
|
|||||||
|
|
||||||
export function onMobileSearch(val) { onHeaderSearch(val); }
|
export function onMobileSearch(val) { onHeaderSearch(val); }
|
||||||
|
|
||||||
// ─── GLOBAL (ITUNES) SEARCH ───
|
// ─── GLOBAL (DEEZER) SEARCH ───
|
||||||
export function onGlobalSearch(val) {
|
export function onGlobalSearch(val) {
|
||||||
clearTimeout(S.globalSearchTimer);
|
clearTimeout(S.globalSearchTimer);
|
||||||
const el = document.getElementById('global-search-results');
|
const el = document.getElementById('global-search-results');
|
||||||
@@ -119,9 +123,9 @@ export async function doGlobalSearch(q) {
|
|||||||
const el = document.getElementById('global-search-results');
|
const el = document.getElementById('global-search-results');
|
||||||
try {
|
try {
|
||||||
const [tracks, albums, artists] = await Promise.all([
|
const [tracks, albums, artists] = await Promise.all([
|
||||||
itunesSearch(q,'musicTrack',25),
|
deezerSearch(q,'musicTrack',25),
|
||||||
itunesSearch(q,'album',10),
|
deezerSearch(q,'album',10),
|
||||||
itunesSearch(q,'musicArtist',6),
|
deezerSearch(q,'musicArtist',6),
|
||||||
]);
|
]);
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
const trackResults = tracks.filter(t => t.wrapperType === 'track');
|
const trackResults = tracks.filter(t => t.wrapperType === 'track');
|
||||||
@@ -132,10 +136,10 @@ export async function doGlobalSearch(q) {
|
|||||||
artists.forEach(a => {
|
artists.forEach(a => {
|
||||||
const sub = a.fanCount ? `${fmtCount(a.fanCount)} Fans` : 'Deezer Künstler';
|
const sub = a.fanCount ? `${fmtCount(a.fanCount)} Fans` : 'Deezer Künstler';
|
||||||
const c = makeCard(a.artistName, sub, a.artworkUrl100 || null, '🎤');
|
const c = makeCard(a.artistName, sub, a.artworkUrl100 || null, '🎤');
|
||||||
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName, a.fanCount);
|
c.onclick = () => loadDeezerArtistResults(a.artistId, a.artistName, a.fanCount);
|
||||||
row.appendChild(c);
|
row.appendChild(c);
|
||||||
if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup
|
if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup
|
||||||
itunesSearch(a.artistName,'album',1).then(res => {
|
deezerSearch(a.artistName,'album',1).then(res => {
|
||||||
if(!res[0]) return;
|
if(!res[0]) return;
|
||||||
const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300');
|
const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300');
|
||||||
if(!imgUrl) return;
|
if(!imgUrl) return;
|
||||||
@@ -157,15 +161,15 @@ export async function doGlobalSearch(q) {
|
|||||||
albums.forEach(a => {
|
albums.forEach(a => {
|
||||||
const art = a.artworkUrl100?.replace('100x100','300x300');
|
const art = a.artworkUrl100?.replace('100x100','300x300');
|
||||||
const c = makeCard(a.collectionName, a.artistName, art, '💿');
|
const c = makeCard(a.collectionName, a.artistName, art, '💿');
|
||||||
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadItunesAlbum(a); };
|
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadDeezerAlbum(a); };
|
||||||
c.querySelector('.dl-btn-overlay')?.remove();
|
c.querySelector('.dl-btn-overlay')?.remove();
|
||||||
const dlOverlay = document.createElement('button');
|
const dlOverlay = document.createElement('button');
|
||||||
dlOverlay.className = 'dl-btn-overlay';
|
dlOverlay.className = 'dl-btn-overlay';
|
||||||
dlOverlay.title = 'Auf Server laden';
|
dlOverlay.title = 'Auf Server laden';
|
||||||
dlOverlay.innerHTML = `<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>`;
|
dlOverlay.innerHTML = `<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>`;
|
||||||
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a}); };
|
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,deezerData:a}); };
|
||||||
c.querySelector('.play-overlay').appendChild(dlOverlay);
|
c.querySelector('.play-overlay').appendChild(dlOverlay);
|
||||||
c.onclick = () => loadItunesAlbum(a);
|
c.onclick = () => loadDeezerAlbum(a);
|
||||||
row.appendChild(c);
|
row.appendChild(c);
|
||||||
});
|
});
|
||||||
s.appendChild(row); el.appendChild(s);
|
s.appendChild(row); el.appendChild(s);
|
||||||
@@ -177,7 +181,7 @@ export async function doGlobalSearch(q) {
|
|||||||
s.appendChild(list); el.appendChild(s);
|
s.appendChild(list); el.appendChild(s);
|
||||||
}
|
}
|
||||||
if(!artists.length && !albums.length && !trackResults.length) {
|
if(!artists.length && !albums.length && !trackResults.length) {
|
||||||
const err = itunesLastError();
|
const err = deezerLastError();
|
||||||
if (err) {
|
if (err) {
|
||||||
// Distinguish "genuinely no results" from "search backend is down" so the user knows where
|
// Distinguish "genuinely no results" from "search backend is down" so the user knows where
|
||||||
// the problem is.
|
// the problem is.
|
||||||
@@ -192,9 +196,9 @@ export async function doGlobalSearch(q) {
|
|||||||
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadItunesAlbum(album) {
|
export async function loadDeezerAlbum(album) {
|
||||||
try {
|
try {
|
||||||
const tracks = await itunesLookupAlbum(album.collectionId);
|
const tracks = await deezerLookupAlbum(album.collectionId);
|
||||||
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
|
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
|
||||||
const el = document.getElementById('global-search-results');
|
const el = document.getElementById('global-search-results');
|
||||||
const s = document.createElement('div'); s.className = 'search-section';
|
const s = document.createElement('div'); s.className = 'search-section';
|
||||||
@@ -204,7 +208,7 @@ export async function loadItunesAlbum(album) {
|
|||||||
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
|
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
|
||||||
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
|
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'"')}})">⬇ Album laden</button>
|
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'"')}})">⬇ Album laden</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-section-title">Tracks</div>`;
|
<div class="search-section-title">Tracks</div>`;
|
||||||
const list = document.createElement('div'); list.className = 'song-list';
|
const list = document.createElement('div'); list.className = 'song-list';
|
||||||
@@ -214,8 +218,8 @@ export async function loadItunesAlbum(album) {
|
|||||||
} catch(e) { toast('Fehler: ' + e.message); }
|
} catch(e) { toast('Fehler: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadItunesArtistResults(artistId, artistName, fanCount) {
|
export function loadDeezerArtistResults(artistId, artistName, fanCount) {
|
||||||
navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId, itunesFanCount: fanCount });
|
navigate('artist-detail', { id: null, name: artistName, deezerArtistId: artistId, deezerFanCount: fanCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SEARCH HELPERS ───
|
// ─── SEARCH HELPERS ───
|
||||||
@@ -227,7 +231,7 @@ export function searchSimilarArtist(artistName) {
|
|||||||
|
|
||||||
export async function searchAndPreview(trackName, artistName) {
|
export async function searchAndPreview(trackName, artistName) {
|
||||||
try {
|
try {
|
||||||
const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1);
|
const res = await deezerSearch(trackName+' '+artistName, 'musicTrack', 1);
|
||||||
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
|
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
|
||||||
} catch { toast('Suche fehlgeschlagen'); }
|
} catch { toast('Suche fehlgeschlagen'); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ function _renderSoundCloudPreview(data) {
|
|||||||
const artT = t.artworkUrl || '';
|
const artT = t.artworkUrl || '';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="gtrack-art">${artT ? `<img src="${artT}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
<div class="gtrack-art">${artT ? `<img src="${artT}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
||||||
|
<div class="gtrack-num">${i + 1}</div>
|
||||||
<div class="gtrack-info">
|
<div class="gtrack-info">
|
||||||
<div class="gtrack-title">${esc(t.trackName || '—')}</div>
|
<div class="gtrack-title">${esc(t.trackName || '—')}</div>
|
||||||
<div class="gtrack-sub">${esc(t.artistName || '—')}${t.genre ? ' · ' + esc(t.genre) : ''}${t.duration ? ' · ' + Math.round(t.duration / 60) + ' min' : ''}</div>
|
<div class="gtrack-sub">${esc(t.artistName || '—')}${t.genre ? ' · ' + esc(t.genre) : ''}${t.duration ? ' · ' + Math.round(t.duration / 60) + ' min' : ''}</div>
|
||||||
|
|||||||
+48
-4
@@ -1,6 +1,6 @@
|
|||||||
import { S } from '../state.js';
|
import { S } from '../state.js';
|
||||||
import { esc, toast } from '../utils.js';
|
import { esc, toast } from '../utils.js';
|
||||||
import { itunesSearch } from '../api.js';
|
import { deezerSearch } from '../api.js';
|
||||||
// Circular imports — safe (only used inside function bodies)
|
// Circular imports — safe (only used inside function bodies)
|
||||||
import { makeGlobalTrackItem } from '../ui/components.js';
|
import { makeGlobalTrackItem } from '../ui/components.js';
|
||||||
import { openDlModal } from '../ui/ui.js';
|
import { openDlModal } from '../ui/ui.js';
|
||||||
@@ -47,6 +47,50 @@ export function onImporterInput(val) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── UNIFIED GLOBAL-SUCHE FIELD ───
|
||||||
|
// One field on the Global-Suche page handles both jobs: a pasted Spotify/SoundCloud/YouTube link
|
||||||
|
// switches into the importer flow; any other text runs the Deezer library search. This replaces the
|
||||||
|
// separate "Song Importer" sidebar entry (the importer now lives inside Global Suche).
|
||||||
|
const _IMPORT_LINK_RE = /^https?:\/\//i;
|
||||||
|
const _IMPORT_HOST_RE = /(?:spotify\.com|soundcloud\.com|youtube\.com|youtu\.be)/i;
|
||||||
|
function _isImportLink(v) { return _IMPORT_LINK_RE.test(v) || _IMPORT_HOST_RE.test(v); }
|
||||||
|
|
||||||
|
export function onGlobalInput(val) {
|
||||||
|
const v = (val || '').trim();
|
||||||
|
const area = document.getElementById('importer-area');
|
||||||
|
const results = document.getElementById('global-search-results');
|
||||||
|
const badge = document.getElementById('importer-badge');
|
||||||
|
const btn = document.getElementById('importer-btn');
|
||||||
|
if (_isImportLink(v)) {
|
||||||
|
// Link → importer mode
|
||||||
|
if (results) results.style.display = 'none';
|
||||||
|
if (area) area.style.display = '';
|
||||||
|
if (badge) badge.style.display = '';
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
|
const hidden = document.getElementById('importer-link-input');
|
||||||
|
if (hidden) hidden.value = v; // mirror so resolveImporterLink() reads it
|
||||||
|
onImporterInput(v); // badge styling + clear stale previews
|
||||||
|
const hint = document.getElementById('importer-hint');
|
||||||
|
if (hint) hint.style.display = ''; // keep the platform hint as a header until a preview loads
|
||||||
|
} else {
|
||||||
|
// Text → Deezer search
|
||||||
|
if (area) area.style.display = 'none';
|
||||||
|
if (badge) badge.style.display = 'none';
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
if (results) results.style.display = '';
|
||||||
|
window.onGlobalSearch(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter in the unified field: resolve the link if it is one (search is already live via oninput).
|
||||||
|
export function tryImporterResolve() {
|
||||||
|
const v = (document.getElementById('global-search-input')?.value || '').trim();
|
||||||
|
if (!_isImportLink(v)) return;
|
||||||
|
const hidden = document.getElementById('importer-link-input');
|
||||||
|
if (hidden) hidden.value = v;
|
||||||
|
resolveImporterLink();
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveImporterLink() {
|
export async function resolveImporterLink() {
|
||||||
const url = document.getElementById('importer-link-input')?.value.trim();
|
const url = document.getElementById('importer-link-input')?.value.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -236,7 +280,7 @@ function _renderSpotifyPreview(data) {
|
|||||||
(data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
|
(data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
|
||||||
section.style.display = '';
|
section.style.display = '';
|
||||||
|
|
||||||
// Lazy-load correct per-track artwork and canonical names via iTunes in the background
|
// Lazy-load correct per-track artwork and canonical names via Deezer in the background
|
||||||
if (count > 0 && S.cfg.backendUrl) {
|
if (count > 0 && S.cfg.backendUrl) {
|
||||||
_loadSpotifyTrackArtwork(data.tracks, list);
|
_loadSpotifyTrackArtwork(data.tracks, list);
|
||||||
}
|
}
|
||||||
@@ -284,7 +328,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
|
|||||||
const idx = i + bi;
|
const idx = i + bi;
|
||||||
try {
|
try {
|
||||||
const firstArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
|
const firstArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
|
||||||
const results = await itunesSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
|
const results = await deezerSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
|
||||||
const match = results[0];
|
const match = results[0];
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
// Always safe to enrich: artwork, preview, explicit, canonical artist name
|
// Always safe to enrich: artwork, preview, explicit, canonical artist name
|
||||||
@@ -305,7 +349,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
|
|||||||
if (match.collectionName) t.collectionName = match.collectionName;
|
if (match.collectionName) t.collectionName = match.collectionName;
|
||||||
if (match.releaseDate) t.releaseDate = match.releaseDate;
|
if (match.releaseDate) t.releaseDate = match.releaseDate;
|
||||||
if (match.trackNumber) t.trackNumber = match.trackNumber;
|
if (match.trackNumber) t.trackNumber = match.trackNumber;
|
||||||
// Only use iTunes duration if it looks like a full song (> 30s) — jingles/previews
|
// Only use Deezer duration if it looks like a full song (> 30s) — jingles/previews
|
||||||
// returning a small trackTimeMillis would corrupt the yt-dlp duration filter.
|
// returning a small trackTimeMillis would corrupt the yt-dlp duration filter.
|
||||||
if (match.trackTimeMillis > 30000) t.durationMs = match.trackTimeMillis;
|
if (match.trackTimeMillis > 30000) t.durationMs = match.trackTimeMillis;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -14,6 +14,11 @@
|
|||||||
}
|
}
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||||
*:focus{outline:none}
|
*:focus{outline:none}
|
||||||
|
/* App-like feel: dragging near controls must not accidentally select UI chrome
|
||||||
|
(page titles, labels, buttons). Real text fields and copyable content opt back in. */
|
||||||
|
#app{-webkit-user-select:none;user-select:none}
|
||||||
|
#app input,#app textarea,#app [contenteditable=true],#app .selectable,
|
||||||
|
#app #np-lyrics-content,#app .lyrics-line,#app .lyrics-plain{-webkit-user-select:text;user-select:text}
|
||||||
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden}
|
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden}
|
||||||
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:2px}
|
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:2px}
|
||||||
|
|
||||||
@@ -69,7 +74,7 @@ html.ns-authed #setup-screen{display:none}
|
|||||||
.header-search-wrap input{flex:1;min-width:0;background:none;border:none;outline:none;color:var(--text);font-family:var(--font);font-size:14px}
|
.header-search-wrap input{flex:1;min-width:0;background:none;border:none;outline:none;color:var(--text);font-family:var(--font);font-size:14px}
|
||||||
.header-search-wrap input::placeholder{color:var(--text3)}
|
.header-search-wrap input::placeholder{color:var(--text3)}
|
||||||
|
|
||||||
#header-search-overlay{position:fixed;left:0;right:0;background:var(--bg);border-bottom:1px solid var(--border);box-shadow:0 12px 32px rgba(0,0,0,.5);padding:6px 12px 20px;z-index:11;overflow-y:auto;max-height:calc(100vh - 120px)}
|
#header-search-overlay{position:absolute;top:100%;left:12px;right:12px;background:var(--bg);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);padding:6px 12px 16px;z-index:11;overflow-y:auto;max-height:calc(100vh - 140px)}
|
||||||
@media(max-width:768px){#header-search-overlay{max-height:calc(100vh - var(--player-h) - 160px)}}
|
@media(max-width:768px){#header-search-overlay{max-height:calc(100vh - var(--player-h) - 160px)}}
|
||||||
@media(max-width:359px){#mobile-header .sidebar-logo span{display:none}}
|
@media(max-width:359px){#mobile-header .sidebar-logo span{display:none}}
|
||||||
|
|
||||||
@@ -209,7 +214,7 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
|
|||||||
/* ── BADGES ── */
|
/* ── BADGES ── */
|
||||||
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:20px;letter-spacing:.3px}
|
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:20px;letter-spacing:.3px}
|
||||||
.badge-new{background:var(--blue-dim);color:var(--blue)}
|
.badge-new{background:var(--blue-dim);color:var(--blue)}
|
||||||
.badge-itunes{background:rgba(252,100,100,.12);color:#fc6464}
|
.badge-deezer{background:rgba(252,100,100,.12);color:#fc6464}
|
||||||
.badge-local{background:var(--green-dim);color:var(--green)}
|
.badge-local{background:var(--green-dim);color:var(--green)}
|
||||||
|
|
||||||
/* ── GLOBAL SEARCH RESULTS ── */
|
/* ── GLOBAL SEARCH RESULTS ── */
|
||||||
@@ -847,7 +852,7 @@ button:focus{outline:none}
|
|||||||
.artist-hero-pfp{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
.artist-hero-pfp{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
|
||||||
.artist-hero-pfp-initial{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700;color:var(--text2);line-height:1}
|
.artist-hero-pfp-initial{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700;color:var(--text2);line-height:1}
|
||||||
.artist-hero-info{min-width:0;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:flex-start;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
|
.artist-hero-info{min-width:0;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:flex-start;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
|
||||||
.artist-hero-name{max-width:100%;font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.artist-hero-name{max-width:100%;font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:.12em}
|
||||||
.artist-hero-fans{font-size:13px;font-weight:600;color:rgba(255,255,255,.58);margin-top:6px;letter-spacing:.2px;display:none}
|
.artist-hero-fans{font-size:13px;font-weight:600;color:rgba(255,255,255,.58);margin-top:6px;letter-spacing:.2px;display:none}
|
||||||
.artist-hero-fans.show{display:block}
|
.artist-hero-fans.show{display:block}
|
||||||
@media(max-width:768px){
|
@media(max-width:768px){
|
||||||
|
|||||||
Reference in new issue
Block a user