Compare commits
216
Commits
| 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 | ||
|
|
569af968c5 | ||
|
|
640e475c51 | ||
|
|
5528947a93 | ||
|
|
9167bfaf0e | ||
|
|
c92ac3d719 | ||
|
|
065d2f7319 | ||
|
|
1e69831dd8 | ||
|
|
a65613b5a1 | ||
|
|
ad433c4dbc | ||
|
|
68a9cdd7d1 | ||
|
|
ccf704754a | ||
|
|
9c8b4775ea | ||
|
|
d98978a6c7 | ||
|
|
966b318d9e | ||
|
|
72b612f5ff | ||
|
|
8cd3e9ac23 | ||
|
|
a3c8b1afd1 | ||
|
|
04a29b684d | ||
|
|
f097dddc64 | ||
|
|
1e242796fe | ||
|
|
14fd5119f2 | ||
|
|
f21174bd2c | ||
|
|
b1a32f5130 | ||
|
|
179e0eb902 | ||
|
|
e78ce43019 | ||
|
|
48a13fbccb | ||
|
|
4d44a476a5 | ||
|
|
bdf2867bdd | ||
|
|
e8b82c177f | ||
|
|
ea98d81146 | ||
|
|
79100c5321 | ||
|
|
3f57e2ae26 | ||
|
|
315b8aaacc | ||
|
|
c5649ac22f | ||
|
|
a80ac3deb0 | ||
|
|
4503bbe54e | ||
|
|
9270d31608 | ||
|
|
e50b4dc93c | ||
|
|
b07212fdce | ||
|
|
419f80568c | ||
|
|
03046cd631 | ||
|
|
03bc736725 | ||
|
|
abb4047966 | ||
|
|
17a28b3011 | ||
|
|
1775ec5b9b | ||
|
|
d50e22b62d | ||
|
|
ef25ac3100 | ||
|
|
ec4d5d0572 | ||
|
|
8741c2fb88 | ||
|
|
445a7bf583 | ||
|
|
63a7260c94 | ||
|
|
ddb9418603 | ||
|
|
7f7d7740e4 | ||
|
|
17a38e9c64 | ||
|
|
7cba39dfaf | ||
|
|
46e252b494 | ||
|
|
be73f717ac | ||
|
|
e1ec1f3660 | ||
|
|
6131eea9ec | ||
|
|
f252586398 | ||
|
|
bed007258a | ||
|
|
420c84d191 | ||
|
|
98d7c84269 | ||
|
|
687650518e | ||
|
|
d65fbcd11e | ||
|
|
7498e45b16 | ||
|
|
ffa4fb35ea | ||
|
|
58dacbabc3 | ||
|
|
211b8ec6c2 | ||
|
|
7a5197b776 | ||
|
|
73fcfae30f | ||
|
|
5a2ab54bbb | ||
|
|
ac8ce6b7d8 | ||
|
|
e246382caf | ||
|
|
c802ef99d7 | ||
|
|
e326ee6350 | ||
|
|
8e81404ab6 | ||
|
|
d4ebcf0447 | ||
|
|
db81595b02 | ||
|
|
3d6b74b873 | ||
|
|
9aa1947568 | ||
|
|
1616d98fa6 | ||
|
|
ff79428683 | ||
|
|
38d21cf2e0 | ||
|
|
d5b088a658 | ||
|
|
fa4c954ab9 | ||
|
|
8be3a515e6 | ||
|
|
a57e18198f | ||
|
|
6e7a18302a | ||
|
|
8b6b4b0839 | ||
|
|
f3fa68928c | ||
|
|
feaa8ee61f | ||
|
|
4c85fec4ca | ||
|
|
0fe2de5084 | ||
|
|
a870f50c87 | ||
|
|
cf6e75b312 | ||
|
|
1e8e5e8874 | ||
|
|
902243586b | ||
|
|
77bbb18c8d | ||
|
|
e93c0c7ab1 | ||
|
|
c4e4c8c090 | ||
|
|
c34123af38 | ||
|
|
9014659112 | ||
|
|
ce4577e8f4 | ||
|
|
bb0be52f46 | ||
|
|
3c54850cca | ||
|
|
4804023a98 | ||
|
|
88d3e336c8 | ||
|
|
d5bc887f31 | ||
|
|
4cbfa35655 | ||
|
|
86d3999628 | ||
|
|
5f257bf451 | ||
|
|
bff652a415 | ||
|
|
d1b107ae71 | ||
|
|
ecb7721e3b | ||
|
|
319a3804f3 | ||
|
|
f1bdd811ca | ||
|
|
925c173c15 | ||
|
|
85252e24df | ||
|
|
7030f029b7 | ||
|
|
93c5fb481d | ||
|
|
43cf11465e | ||
|
|
6a1bf33f31 | ||
|
|
9ffd071c58 | ||
|
|
dd484df636 | ||
|
|
6be7981b8a | ||
|
|
eacce487df | ||
|
|
ec9c0add9e | ||
|
|
72feac41bb | ||
|
|
820a75f40b | ||
|
|
3280ffee37 | ||
|
|
ba16f88355 | ||
|
|
613d5156fa | ||
|
|
a0d3c7a592 | ||
|
|
f7012dab49 | ||
|
|
0272b9e99f | ||
|
|
eb5b0ea360 | ||
|
|
d7f8b301ab | ||
|
|
d0df01a76d | ||
|
|
71667d1a03 | ||
|
|
8b9bfd3098 |
No files matched your search
+2
-11
@@ -3,6 +3,7 @@
|
||||
# Pre-fills the URLs on the setup screen
|
||||
NAVIDROME_URL=https://music.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
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
@@ -11,17 +12,7 @@ AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
||||
MAX_PARALLEL=2
|
||||
|
||||
# Download backend: ytdlp | squid | auto
|
||||
# ytdlp - always use yt-dlp
|
||||
# squid - always use squid.wtf (Qobuz)
|
||||
# auto - try squid.wtf first, fall back to yt-dlp if not found (default)
|
||||
DOWNLOAD_BACKEND=auto
|
||||
|
||||
# Quality for squid.wtf / Qobuz downloads
|
||||
# mp3 - MP3 320 kbps
|
||||
# flac - FLAC 16-bit / 44.1 kHz
|
||||
# hi-res - FLAC 24-bit / 96 kHz
|
||||
SQUID_QUALITY=mp3
|
||||
DOWNLOAD_BACKEND=ytdlp
|
||||
|
||||
# Ticketmaster Events (optional — free API key)
|
||||
# 1. Sign up at https://developer.ticketmaster.com/
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to NovaSonic are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together).
|
||||
|
||||
---
|
||||
|
||||
## Platform Parity Matrix
|
||||
|
||||
| Feature | Web (PWA) | Android |
|
||||
|----------------------------------|:---------:|:-------:|
|
||||
| Music playback (Navidrome) | ✅ | ✅ |
|
||||
| Seek bar / scrubbing | ✅ | ✅ |
|
||||
| Queue management | ✅ | ✅ |
|
||||
| Shuffle / Repeat | ✅ | ✅ |
|
||||
| Radio mode (similar songs) | ✅ | ✅ |
|
||||
| Now Playing overlay | ✅ | ✅ |
|
||||
| Dominant color background | ✅ | ✅ |
|
||||
| Cover art scale animation | ❌ | ✅ |
|
||||
| Synced lyrics (LRCLIB) | ✅ | ✅ |
|
||||
| Lyrics autoscroll | ✅ | ✅ |
|
||||
| Star / favorite song | ✅ | ✅ |
|
||||
| Multi-artist navigation | ✅ | ✅ |
|
||||
| Album detail page | ✅ | ✅ |
|
||||
| Artist page (Navidrome) | ✅ | ✅ |
|
||||
| Artist page (Deezer fallback) | ✅ | ✅ |
|
||||
| Global search source | Deezer | Deezer |
|
||||
| Same-name artist disambiguation | ✅ | ✅ |
|
||||
| Artist fan count shown | ✅ | ✅ |
|
||||
| 30s preview control bar | ✅ | ✅ |
|
||||
| Concert events (Ticketmaster) | ✅ | ✅ |
|
||||
| Genre browsing | ✅ | ✅ |
|
||||
| Global search | ✅ | ✅ |
|
||||
| Playlist browsing | ✅ | ✅ |
|
||||
| Playlist create/edit | ✅ | ✅ |
|
||||
| Playlist cover upload | ✅ | ✅ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Metadata editor (tags + cover) | ✅ | ❌ |
|
||||
| Song delete (from disk) | ✅ | ❌ |
|
||||
| Import notification | ✅ | ✅ |
|
||||
| Import notification → nav | ✅ | ✅ |
|
||||
| ABS (Audiobookshelf) playback | ✅ | ✅ |
|
||||
| ABS chapter navigation | ✅ | ✅ |
|
||||
| ABS sleep timer | ✅ | ✅ |
|
||||
| ABS playback speed | ✅ | ✅ |
|
||||
| ABS library search | ✅ | ✅ |
|
||||
| ABS book descriptions (HTML) | ✅ | ✅ |
|
||||
| Media notification | ✅ | ✅ |
|
||||
| ABS notification (modern style) | ❌ | ✅ |
|
||||
| Android Auto | — | ✅ |
|
||||
| Offline download (albums/playlists) | ❌ | ✅ |
|
||||
| Offline download (single songs) | ❌ | ✅ |
|
||||
| Offline playback | ❌ | ✅ |
|
||||
| Offline detail / manage downloads | ❌ | ✅ |
|
||||
| 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 | ✅ | ❌ |
|
||||
| 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
|
||||
|
||||
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.
|
||||
|
||||
### Changed
|
||||
- **Web + Android + Backend** — Global search, album/artist lookups, artist top tracks and the "Neu & Beliebt" charts all run on **Deezer** (`api.deezer.com`). Results are mapped to the existing item shape, so the UI is unchanged; artist cards now show real photos (iTunes artist entities had none)
|
||||
- **Backend (download)** — Single-track, album and **artist** downloads, plus the metadata-editor autofill, now enrich from Deezer:
|
||||
- `backend/lib/deezer.js` — new module: `lookupTrackMeta`, `fetchAlbumTracks`, `searchAlbumTracks`, `fetchArtistAlbums`
|
||||
- Album/artist `album_artist` still comes from the album-level artist (Deezer `album.artist`), keeping soundtracks from splitting
|
||||
- Genre comes from the Deezer album (one album-level genre); per-track artist is preserved for mixed-artist albums
|
||||
- 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)
|
||||
- **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
|
||||
- **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)
|
||||
- **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away (the local artist page had its *own* uncontrollable preview too). All previews now run through one app-level player with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview
|
||||
- **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
|
||||
- **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)
|
||||
|
||||
### 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`
|
||||
- Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer
|
||||
|
||||
---
|
||||
|
||||
## [1.2] — 2026-07-25
|
||||
|
||||
Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish.
|
||||
|
||||
### Added
|
||||
- **Android** — Directional bottom-nav tab transitions: the slide follows the tapped tab's position (a tab to the right slides in from the right, one to the left from the left) instead of a fixed, sometimes-random-feeling direction. Applied across all four enter/exit/pop transitions; detail-screen push/pop is unchanged
|
||||
- **Web** — Pressing **Enter** in the importer link field starts the import (same as the Importieren button)
|
||||
|
||||
### Fixed
|
||||
- **Web** — Browser / mouse **Forward** navigation now works (previously only Back did): `popstate` applies the target history entry (`nsIdx`) for both directions, and the back handler no longer `pushState()`s over the browser's forward stack. In-app back buttons route through `history.back()` so the browser and app stacks stay in sync
|
||||
- **Web** — Deleting a song or playlist refreshes the current view immediately (drops the cached list) instead of leaving the removed item visible for up to ~3 minutes
|
||||
- **Web** — Album detail header showed the **first track's** artist (e.g. "TWICE" on a soundtrack) instead of the album artist; it now reads the album's own album-artist fields from `getAlbum` (matches the Android app)
|
||||
- **Web** — Metadata autofill (iTunes / MusicBrainz) normalizes the trailing `" & "` in a multi-artist credit ("A, B & C" → "A; B; C"), but only when the value is already a list, so band names like "Simon & Garfunkel" stay intact
|
||||
- **Android** — Erkunden (Apple) overlay back no longer *sometimes* jumped to Home: the root-level back callback is re-asserted on every navigation, so it can't lose back-priority to the NavHost after an album drill-down or tab switch
|
||||
- **Backend (download)** — Mixed-artist albums search **per track**: the YTM/YouTube query and channel filter use each track's own performer, so soundtrack tracks by other artists (TWICE's "Strategy", MeloMance's "Love, Maybe") are found instead of failing with "Nicht eindeutig gefunden"
|
||||
- **Backend (download)** — `album_artist` is derived from iTunes' album-level `collectionArtistName` (identical across the album) instead of the request's artist — which could be the lead single's performer, so a whole KPop soundtrack was tagged `album_artist = TWICE`. Applies to album and artist downloads; each track still gets its own per-track artist tag
|
||||
- **Backend (download)** — Official audio is matched by yt-dlp's `artist` metadata, not just the channel name, so uploads on label channels (BANGTANTV, HYBE, "… - Topic") win over fan reuploads — e.g. BTS "Pied Piper" now grabs the official track. (`channel_is_verified` was rejected as a signal: lyric videos and fan MVs are also on verified channels)
|
||||
- **Backend (download)** — Western artists are no longer tagged with the Japanese store's katakana album-artist ("DJスネイク" for DJ Snake): the JP-first iTunes lookup now keeps Japanese names only for Japanese songs and uses the US/English names otherwise (affects the `album_artist` tag and the on-disk folder)
|
||||
- **Backend (download)** — Release year is back-filled from iTunes on single-track downloads where the source omitted it (the year tag was previously left empty)
|
||||
|
||||
### Changed
|
||||
- **Web + Android** — Playing a song from search results queues only that song; Radio mode (if on) fills the rest — closer to Spotify. Album / playlist play still queues the whole list
|
||||
- **Android** — NowPlaying track info is ordered title → album → artists, matching the web player
|
||||
- **Web** — NowPlaying favorite star is vertically centered with the whole title / album / artist block instead of pinned to the title line
|
||||
|
||||
---
|
||||
|
||||
## [1.1] — 2026-07-19
|
||||
|
||||
Polish release: Android navigation and offline resilience, download accuracy for artists with stylised names, and a fix for the lyrics outage caused by lrclib rate-limiting.
|
||||
|
||||
### Added
|
||||
- **Android** — Mini player is now visible on every screen (album, artist, playlist detail), not only the top-level tabs; the tab bar stays top-level-only
|
||||
- **Android** — Song search results (local library) got the 3-dot context menu: play, insert next / queue, add to playlist, favorite, go to album, song info, download
|
||||
- **Android** — Artist images in the Apple-Music (Erkunden) search: iTunes artist entities carry no artwork at all, so each artist now borrows the cover of one of their albums; a person icon replaces the empty grey circle where none is found
|
||||
- **Web + Android** — Playlist cover art is shown in the "add to playlist" picker, so playlists are identifiable at a glance
|
||||
- **Web** — "Now playing" scrobble is sent at track start (`scrobble?submission=false`), so Navidrome shows the current track while it plays instead of only on completion
|
||||
|
||||
### Fixed
|
||||
- **Web + Android** — Lyrics stopped loading entirely: a burst of requests (prefetch + the 4-request cascade + fast skipping) got the client IP throttled by lrclib, so every lookup hung until it timed out. Fixed with per-song in-flight de-duplication, a prefetch debounced to 1.5 s after track start, a descriptive User-Agent, and a longer 8 s timeout
|
||||
- **Android** — App lag and stutter while scrolling and switching tabs: `coverArtUrl()` and `streamUrl()` built a fresh Ktor `HttpClient` for every cover-art request and never closed it — heavy allocation and GC churn. Both now reuse the cached client
|
||||
- **Android** — Star / favorite gave no visual feedback: a global `StarState` now flips the icon optimistically the moment it's tapped (everywhere it appears) and rolls back if the API call fails
|
||||
- **Android** — Back from an Erkunden (Apple) artist or album jumped straight to Home. Those screens are in-screen overlays on the `erkunden` destination and their `BackHandler` lost back priority to the NavHost, which popped the whole tab. Back is handled at the app root now: album → artist, artist → search
|
||||
- **Android** — The Erkunden artist's "show all albums" view was lost when drilling into an album and back (it lived in local `remember` state); the open section now lives in the ViewModel and survives both route and overlay round-trips
|
||||
- **Android** — Album back-navigation from a local artist returned to the collapsed artist page instead of the "all albums" view it was opened from
|
||||
- **Android** — The mini player sat behind the phone's gesture/navigation bar on detail screens, where there is no tab bar to absorb the system inset
|
||||
- **Android** — The download sheet for new songs and the song context sheet opened only half-expanded, hiding the download button behind the system navigation bar until dragged up; both open fully now
|
||||
- **Android** — The Offline tab was blocked by the online library's loading spinner, so downloads were unreachable while the library loaded or the server was unreachable. It reads from the local Room DB and now loads independently
|
||||
- **Backend (download)** — Artist lookups ignored leading aesthetic punctuation (e.g. `.diedlonely`), so the iTunes match missed, fell through to a title-only match, and grabbed an unrelated same-titled track — whose wrong duration then also misled the YouTube search filter. Leading non-alphanumeric characters are trimmed before lookup
|
||||
- **Backend (metadata)** — The metadata editor showed "[Unknown Album]" where the library shows "Singles": tracks downloaded without an album had no album tag written at all. The album tag now always gets a value (defaulting to `Singles`), and file resolution falls back to `findExistingTrack` for stale or rebased paths
|
||||
- **Web** — Now Playing showed a stale cover after rapid skips
|
||||
- **Web** — Home row scroll arrows caused layout thrash while scrolling: arrow visibility is a rAF-throttled class toggle now, with `getBoundingClientRect()` only on resize / content change
|
||||
|
||||
### Changed
|
||||
- **Android** — Favorite is a heart everywhere; the Now Playing screen still used a star
|
||||
- **Android** — Playlist detail actions are uniform, equal-width and icon-only (play / shuffle / edit / download). Since the offline-download button was added, four buttons shared one row and the Play/Shuffle labels were being clipped away
|
||||
- **Android** — The "add to playlist" search field matches the styling of the other search bars
|
||||
- **Web** — Track switching uses a gapless hand-off between two `<audio>` elements, making skips effectively instant (it does not fix the Windows media-tile flicker — see Known Bugs)
|
||||
|
||||
---
|
||||
|
||||
## [1.0] — 2026-06-25
|
||||
|
||||
🎉 **First stable release.** NovaSonic is feature-complete across web (PWA) and Android: streaming, lyrics, artist pages, playlists, importer/downloads, Audiobookshelf, and — new in this release — full offline downloads on Android. Further work lands as point releases.
|
||||
|
||||
### Added
|
||||
- **Android** — Offline downloads: download albums and playlists to device storage with a single tap; album download button is in the album hero row (▶ / shuffle / ↓), playlist button next to the edit toggle. Tapping again resumes / re-downloads any missing songs (already-downloaded tracks are skipped)
|
||||
- **Android** — Offline tab in Library (position 2, next to Playlists): lists downloaded groups with cover art, track count, and an in-progress bar; shows total MB used
|
||||
- **Android** — Offline detail screen: tap a group to view its songs (with cover art, like the online playlist), play / shuffle, and remove individual songs from the device via the edit toggle. Works fully offline (reads from Room, no network)
|
||||
- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
|
||||
- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
|
||||
- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
|
||||
- **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change
|
||||
- **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome)
|
||||
- **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields
|
||||
- **Web** — Horizontal rows (Home) now have hover scroll arrows; only the scrollable direction is shown (no left arrow at the start, no right arrow at the end)
|
||||
|
||||
### Fixed
|
||||
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
||||
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
||||
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
|
||||
- **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state
|
||||
- **Web (PWA)** — Media Session metadata is now mutated in place instead of replaced with a new `MediaMetadata` each track (reduces churn). Note: this alone does not stop the Windows media tile from briefly closing/reopening on skip — see Known Bugs
|
||||
|
||||
### Internal
|
||||
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
||||
|
||||
---
|
||||
|
||||
## [0.9] — 2026-06-22
|
||||
|
||||
### Added
|
||||
- **UI** — New NovaSonic app icon: 5-bar equalizer waveform with blue→purple gradient (`#7eb8ff` → `#8b3cf7`) on dark background — replaces the generic music-note placeholder
|
||||
- **UI** — Favicon link added to `<head>` with SVG + PNG fallback (`icon-192.png`) so browser tabs show the icon
|
||||
- **UI** — Android launcher icon (`ic_launcher.xml`) updated to match the new equalizer design using the same blue→purple gradient via `aapt:attr`
|
||||
- **UI (web)** — Sidebar logo updated from ♫ emoji to the new equalizer SVG (`icon.svg`)
|
||||
- **nginx** — `/app/` alias added so reverse-proxy deployments can resolve static assets (icon, manifest) under the `/app` prefix without Vite `base` changes
|
||||
|
||||
### Fixed
|
||||
- **UI** — Player bar star/favorite button now vertically centred with the full track info block (title + album + artist), not pinned to the top of the title line
|
||||
- **UI** — Shuffle status badge no longer shifts when the player bar is active (removed `overflow:hidden` from `.player-info-wrap` that was clipping the status row)
|
||||
- **UI (mobile)** — Duplicate NovaSonic logo + name removed from mobile header next to the search bar
|
||||
- **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
|
||||
|
||||
### Changed
|
||||
- **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** — `fix-artist-sort` script now uses the original (kanji) artist name for folder and filename; `artistSort` (romaji) is only written to ARTISTSORT / ALBUMARTISTSORT sort tags
|
||||
- **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
|
||||
|
||||
### Investigated (album splitting root causes)
|
||||
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
|
||||
|
||||
### Removed
|
||||
- **Backend** — squid.wtf (Qobuz) download backend removed; downloads now exclusively use yt-dlp. `DOWNLOAD_BACKEND` and `SQUID_QUALITY` env vars are no longer read.
|
||||
|
||||
### Changed
|
||||
- **Backend** — Download cascade "Mittelweg": when a track's duration is known but no result matches the duration window, the cascade no longer falls back to blind title-only/no-filter grabs (which pulled same-titled videos by other uploaders). It now relaxes only the duration constraint while still requiring the artist channel — and if nothing matches, the job fails cleanly with "Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen" instead of silently saving the wrong song
|
||||
- **Backend** — Duration match margin tightened from ±15% to ±10% (min 10s) to reject near-miss false positives
|
||||
- **Backend** — YTM search uses the `web_music` player client so YouTube Music "Art Track" / topic uploads are reachable (the previous `android_vr` client reported many as "video not available")
|
||||
- **Backend** — Search query uses a space between artist and title ("MINA recognize") instead of a dash, matching how the YTM search box ranks the exact track
|
||||
|
||||
### Fixed
|
||||
- **Backend** — YTM search query: album name was appended for short-title tracks, causing duplicated words (e.g. `"MINA - recognize recognize Single"`); album is now only appended for Japanese titles, matching spotdl behaviour
|
||||
- **Backend** — Downloaded filename now strips the release suffix → `01 - Artist - Title.mp3` (no `- Single`/`- EP` in the filename); the album folder keeps the full album name. The embedded title tag is cleaned the same way
|
||||
- **Backend** — Duration metadata from the iTunes resolver now reaches the yt-dlp duration filter for all importer paths (single, album, playlist, global search), so the right-length track is preferred
|
||||
- **Backend** — Metadata editor / song delete: file resolution now tolerates a release-suffix mismatch between Navidrome's stored stem and the on-disk filename; the delete "file not found" error now reports the actual resolved path for diagnosis
|
||||
- **Backend** — Download no longer aborts the search cascade when a single result is geo-blocked or unavailable (e.g. "This video is not available") — it continues to the next candidate
|
||||
|
||||
---
|
||||
|
||||
## [0.7] — 2026-06-18
|
||||
|
||||
### Added
|
||||
- **Android** — Playlist create: "+" FAB on the Playlists tab opens a dialog to create a new playlist
|
||||
- **Android** — Playlist edit dialog (pencil icon): edit name, description, and upload cover — all in one place; matches web edit modal
|
||||
- **Android** — Playlist delete: trash icon in the playlist hero with confirmation dialog; navigates back to library on success
|
||||
- **Android** — Playlist song removal: edit-mode toggle in the playlist action row reveals a "−" button per song; removes via `updatePlaylist.view?songIndexToRemove`
|
||||
- **Android** — Playlist cover upload: image picker in the edit dialog uploads via Navidrome REST API (`POST /api/playlist/{id}/image`)
|
||||
- **Android** — ABS notification: current chapter name shown as the main title; book title moves to the artist line; updates automatically on chapter change without interrupting playback
|
||||
- **Android** — Home "Zuletzt gehört" section: recently played albums row matching the web's `type=recent` row
|
||||
- **Android** — Library Albums: sort chips (Neueste / Älteste / A-Z); Neueste uses `type=newest` from Navidrome (add-date order); scrolls to top on sort change
|
||||
- **Android** — Artist fast-scroll index bar: draggable letter strip on the right side of the Artists list with letter bubble while dragging
|
||||
- **Web** — Playlist edit modal: "Cover entfernen" button removed
|
||||
|
||||
### Fixed
|
||||
- **Android** — In-app notification bell: notifications were never delivered after navigating away from the Importer screen (poll ran in `viewModelScope`, cancelled on navigation); polling now uses `NotificationStore.startPoll()` with an app-lifetime scope
|
||||
- **Android** — Home hero card: pool now matches web — union of `frequent(5) ∪ recentlyPlayed(5)` deduped, random pick; label is "MEISTGESPIELT" or "ZULETZT GEHÖRT" (was hardcoded "AKTUELL")
|
||||
- **Android** — Library Albums "Neueste": was incorrectly sorting by release year client-side; now uses `type=newest` from Navidrome (recently added to library)
|
||||
- **Android** — Artist screen crash: duplicate key in `LazyColumn` when Navidrome returns the same song ID twice in `topSongs`
|
||||
- **Android** — "Alle Alben anzeigen" crash: duplicate key when two albums share name + year
|
||||
- **Android** — "Alle Alben anzeigen": albums were left-aligned inside grid cells; now centered
|
||||
- **Android** — "Alle Alben anzeigen" search bar styling matches app design
|
||||
- **Android** — ABS notification button order: Samsung One UI mapping corrected; now shows Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor
|
||||
|
||||
---
|
||||
|
||||
## [0.6] — 2026-06-17
|
||||
|
||||
### Added
|
||||
- **Android** — Lyrics preload on NowPlaying open; lyrics load in background while on Player tab, so switching to Lyrics tab is instant
|
||||
|
||||
### Fixed
|
||||
- **Android** — Home hero card: play button now sits to the right of title/artist, not below
|
||||
- **Android** — ABS media notification now uses Media3's modern media card style (blurred cover art background) instead of a plain notification; identical look to the music notification
|
||||
- **Android** — ABS notification buttons corrected: now shows exactly Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor in both compact and expanded view; previously showed double back-button and 30s-forward was cut off
|
||||
- **Android** — Lyrics autoscroll switches from fixed tween to spring animation (DampingRatioNoBouncy, StiffnessLow); rapid line changes no longer produce choppy stop-and-restart scrolling
|
||||
|
||||
### Internal
|
||||
- **Android** — ABS notification rebuilt using a custom `DefaultMediaNotificationProvider` subclass (`AbsAwareNotificationProvider`) registered via `setMediaNotificationProvider()`; overrides `getMediaButtons()` to return exactly 5 session-command buttons when ABS is active, bypassing `setCustomLayout()` which only appends to (not replaces) the standard skip-prev/play-pause/skip-next buttons
|
||||
- **Android** — Removed abandoned `AbsPlayerService.kt` (unreferenced, unregistered, referenced missing drawable)
|
||||
|
||||
---
|
||||
|
||||
## [0.5] — 2026-06
|
||||
|
||||
### Added
|
||||
- **Android** — ABS full playback: chapter list, playback speed control, sleep timer, progress sync to ABS server
|
||||
- **Android** — ABS 30s skip-back / skip-forward buttons in media notification (compact + expanded view)
|
||||
- **Android** — ABS library search bar in Books tab (server-side search via `/api/libraries/{id}/search`, 400ms debounce)
|
||||
- **Android** — Notification sheet: shows import/download history with status icons; "Zum Album →" / "Öffnen →" button navigates to album or playlist on tap
|
||||
- **Web** — Notification sheet: "Zum Album →" link button after download completes
|
||||
- **Android** — NowPlaying lyrics: 200ms lookahead for better sync feel (matches web)
|
||||
|
||||
### Fixed
|
||||
- **Web + Android** — ABS book descriptions: HTML tags (`<p>`, `</p>`) now stripped before display
|
||||
- **Android** — NowPlaying background: switched from `darkMutedSwatch` to `vibrantSwatch` (55% darken) for more visible color tinting
|
||||
- **Android** — NowPlaying top bar: fixed layout overflow with navigation labels
|
||||
- **Android** — Lyrics scroll: removed Phase 2 "drift" animation; now simply centers active line (matches web `scrollTo({ behavior: 'smooth' })`)
|
||||
- **Android** — ABS skip: library list skip fixed on first load
|
||||
|
||||
---
|
||||
|
||||
## [0.4] — 2026-05
|
||||
|
||||
### Added
|
||||
- **Android** — Full native app rewrite in Kotlin / Jetpack Compose (replaces React Native / Expo attempt)
|
||||
- **Android** — Media3 / ExoPlayer music playback with `MusicService` (`MediaLibraryService`)
|
||||
- **Android** — NowPlaying screen: cover art with scale animation (Apple Music style), seek bar with drag scrubbing, queue tab, lyrics tab
|
||||
- **Android** — Dominant color background in NowPlaying (Palette API, animated on song change)
|
||||
- **Android** — Multi-artist display with per-artist tap-to-navigate in NowPlaying
|
||||
- **Android** — Synced lyrics via LRCLIB (parallel requests, in-memory cache)
|
||||
- **Android** — ABS (Audiobookshelf) integration: login, library browse, book detail, chapter playback
|
||||
- **Android** — Android Auto support: browsable tree (Recent, Favorites, Playlists), play-all / shuffle pseudo-items
|
||||
- **Android** — Radio mode: auto-queues similar songs via Navidrome `getSimilarSongs`
|
||||
- **Android** — Global search (Navidrome)
|
||||
- **Android** — Genre browsing with play button per genre
|
||||
- **Android** — Concert events via Ticketmaster on artist page
|
||||
- **Android** — Spotify / YouTube importer (connects to backend)
|
||||
- **Android** — Notification sheet in-app (import/download job progress)
|
||||
- **Android** — Settings: Navidrome URL/credentials, ABS URL/token, theme, display name
|
||||
|
||||
### Fixed
|
||||
- **Android** — Login flash on startup (credentials loaded before nav resolves)
|
||||
- **Android** — Album name casing normalization
|
||||
|
||||
---
|
||||
|
||||
## [0.3] — 2025-Q4
|
||||
|
||||
### Added
|
||||
- **Web** — Artist page: hero banner, iTunes + Navidrome album categorization, all-songs view
|
||||
- **Web** — Concert events via Ticketmaster (replaces Bandsintown); 30min server-side cache
|
||||
- **Web** — Playlist editing: name, description, public toggle, cover image upload
|
||||
- **Web** — Global search: artist cards with images, explicit badge (E)
|
||||
- **Web** — Romaji display for Japanese artists/albums (Navidrome `sortName` tag)
|
||||
- **Web** — iTunes album detail page (for tracks not in Navidrome)
|
||||
- **Web** — Mobile search bar in header (tap icon)
|
||||
- **Web** — Smooth progress bar scrubbing with touch drag
|
||||
|
||||
### Fixed
|
||||
- **Web** — Multi-artist splitting: handles `·`, `/`, `;` separators; each name navigates to own artist profile
|
||||
- **Web** — Lyrics: LRCLIB fuzzy fallback, title-only search fallback, parallel requests, in-memory cache
|
||||
- **Web** — Lyrics scrollbar hidden, fade edges on active line
|
||||
- **Web** — Artwork: hi-res cover embedded; consistent `album_artist` tag for Navidrome grouping
|
||||
- **Web** — `feat.`/`ft.`/`featuring` converted to `/` in artist tag for correct Navidrome multi-artist split
|
||||
|
||||
---
|
||||
|
||||
## [0.2] — 2025-Q3
|
||||
|
||||
### Added
|
||||
- **Web** — Spotify / YouTube / SoundCloud importer: resolve link → yt-dlp download → optional Navidrome playlist creation
|
||||
- **Web** — iTunes per-track artwork lookup for playlist downloads
|
||||
- **Web** — Synced lyrics via LRCLIB in Now Playing overlay (200ms lookahead)
|
||||
- **Web** — Lyrics embedding in downloaded track metadata
|
||||
- **Web** — Explicit metadata tag + E badge in search
|
||||
- **Web** — Disc number and compilation metadata tags
|
||||
- **Web** — Genre metadata from iTunes `primaryGenreName`
|
||||
- **Web** — SW update banner (notify user, no force-reload)
|
||||
- **Web** — Duplicate detection before download (shows toast on skip)
|
||||
- **Web** — Player drag feedback; swipe-up player bar opens Now Playing
|
||||
|
||||
### Fixed
|
||||
- **Web** — Spotify importer: multi-artist parsing, GraphQL embed format, locale URL support, public web-player token
|
||||
- **Web** — Playlist cover auto-refresh after upload
|
||||
- **Web** — Service worker: network-first strategy, does not intercept cross-origin requests, auto-reloads all PWA windows on SW update
|
||||
- **Web** — Bottom navigation: full-width edge-to-edge layout
|
||||
|
||||
---
|
||||
|
||||
## [0.1] — 2025-Q2
|
||||
|
||||
### Added
|
||||
- **Web** — Initial PWA music player (Vite + ES modules)
|
||||
- **Web** — Navidrome Subsonic API integration (albums, songs, playlists, search, cover art)
|
||||
- **Web** — Album and playlist browsing
|
||||
- **Web** — Basic player (play/pause, skip, shuffle, repeat)
|
||||
- **Web** — Media Session API (lock screen / notification controls)
|
||||
- **Web** — Service worker with offline support
|
||||
- **Web** — Docker setup: nginx frontend + Node.js backend + Navidrome
|
||||
- **Web** — Home screen: hero card (last played album), genre grid, recently added, most played, discover
|
||||
|
||||
---
|
||||
|
||||
## Planned / Backlog
|
||||
|
||||
### 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:
|
||||
- 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.
|
||||
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
|
||||
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
|
||||
- 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:
|
||||
- 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).
|
||||
- **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.
|
||||
- **Android** — Playlist drag-and-drop / sort order
|
||||
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
|
||||
- **Android** — Romaji / Japanese sort support
|
||||
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
||||
- **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
|
||||
- **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.
|
||||
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
|
||||
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
|
||||
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
|
||||
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
|
||||
@@ -0,0 +1,213 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
NovaSonic — a self-hosted music streaming **PWA** (Vite + vanilla ES modules) and
|
||||
**Android app** (Jetpack Compose + Media3/ExoPlayer) for Navidrome / Subsonic servers,
|
||||
plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies APIs.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never hardcode or mock frontend data.** All library data comes from the Navidrome
|
||||
(Subsonic) API. No placeholder songs/albums/artists, ever.
|
||||
- **Don't commit or push unless asked.** When asked, push to the Gitea remote.
|
||||
- **Two-part versioning** `MAJOR.MINOR` (e.g. `0.8`) — no patch component. Each release
|
||||
bumps the minor; features and fixes go in the same release. See `CHANGELOG.md`.
|
||||
- Keep the `CHANGELOG.md` Platform Parity Matrix accurate when adding cross-platform features.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
|
||||
Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
|
||||
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
|
||||
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
|
||||
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
|
||||
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
|
||||
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
|
||||
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||||
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||||
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
||||
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
|
||||
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||||
metadata editor are **not** in the Android app).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (HMR)
|
||||
npm run build # production build → dist/
|
||||
npm run preview # preview the production build
|
||||
```
|
||||
|
||||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
||||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
||||
(this also re-pulls the latest yt-dlp).
|
||||
|
||||
## Download pipeline (hard-won knowledge)
|
||||
|
||||
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
|
||||
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
|
||||
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
|
||||
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
|
||||
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
|
||||
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
|
||||
artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
|
||||
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
|
||||
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
|
||||
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
|
||||
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
|
||||
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
|
||||
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
|
||||
`backend/test/deezer.test.js` (`npm test`).
|
||||
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
|
||||
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
|
||||
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
|
||||
for Japanese tracks. Romaji goes **only** into sort tags (TITLESORT / ARTISTSORT / etc.).
|
||||
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
|
||||
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
|
||||
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
|
||||
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
|
||||
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
|
||||
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
|
||||
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
|
||||
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
|
||||
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
|
||||
`--match-filter` args (yt-dlp OR's them) in `mf()`.
|
||||
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
|
||||
relax only the duration constraint while **still requiring the artist channel**. If nothing
|
||||
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
|
||||
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
|
||||
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
|
||||
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
|
||||
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
||||
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||||
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
|
||||
Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
|
||||
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
|
||||
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
|
||||
runs client-side in Android's `ExploreViewModel.search()`.
|
||||
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
|
||||
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
|
||||
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
|
||||
so without this every track on a soundtrack searched the album-artist blob + its channel filter and
|
||||
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
|
||||
still uses `albumArtist`, so album grouping is unaffected.
|
||||
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
|
||||
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
|
||||
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
|
||||
tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
|
||||
Deezer omits it.
|
||||
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
|
||||
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
|
||||
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
|
||||
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
||||
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
||||
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
|
||||
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
|
||||
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
|
||||
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
|
||||
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
|
||||
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
|
||||
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
|
||||
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
|
||||
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
|
||||
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
|
||||
(`deezer.test.js`, `musicbrainz.test.js`).
|
||||
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
||||
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
||||
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||||
(`sourceUrl` path) is the reliable route.
|
||||
- **File resolution for edit/delete:** Navidrome's stored path may use a different mount root
|
||||
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
|
||||
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
|
||||
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
|
||||
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||||
|
||||
## Listening stats / scrobble system (v2.0)
|
||||
|
||||
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
|
||||
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
|
||||
records every counted play into its **own** timestamped, multi-user log.
|
||||
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
|
||||
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
|
||||
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
|
||||
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
|
||||
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
|
||||
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
|
||||
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
|
||||
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
|
||||
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
|
||||
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
|
||||
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
|
||||
`_toEpochSec`, which maps `0`→now).
|
||||
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
|
||||
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
|
||||
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
|
||||
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
|
||||
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
|
||||
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
|
||||
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
|
||||
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
|
||||
batch (server dedups by uuid) on start / when connectivity returns.
|
||||
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
|
||||
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
|
||||
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
|
||||
cover-agnostic; the stats endpoint just returns the IDs.
|
||||
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
|
||||
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
|
||||
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
|
||||
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
|
||||
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
|
||||
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
|
||||
`docs/scrobble-plan.md`.
|
||||
|
||||
## Android offline system
|
||||
|
||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||
- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
|
||||
- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
|
||||
- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
|
||||
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
||||
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
||||
|
||||
## Android navigation, state & insets (hard-won)
|
||||
|
||||
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
|
||||
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
|
||||
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
|
||||
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
||||
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
|
||||
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
|
||||
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
|
||||
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
|
||||
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
|
||||
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
|
||||
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
|
||||
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
|
||||
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
|
||||
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||||
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
|
||||
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
|
||||
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
|
||||
the phone's gesture/navigation bar.
|
||||
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
|
||||
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
|
||||
hidden behind the navigation bar and needs an extra drag-up.
|
||||
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
|
||||
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
|
||||
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
|
||||
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
|
||||
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
|
||||
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
|
||||
album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
|
||||
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
|
||||
|
||||
## UI / references
|
||||
|
||||
When referencing files in chat, use clickable markdown links, not backticks:
|
||||
`[ytdlp.js](backend/lib/ytdlp.js)`.
|
||||
@@ -1,70 +1,24 @@
|
||||
# NovaSonic
|
||||
|
||||
A self-hosted music streaming PWA and Android app for Navidrome / Subsonic-compatible servers.
|
||||
Built with Vite + vanilla ES modules, served via nginx in Docker.
|
||||
Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlayer (Android).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Music Library
|
||||
- Stream music from your Navidrome / Subsonic library
|
||||
- Browse artists, albums, playlists, starred tracks, and random picks
|
||||
- Sort and filter views with chip filters
|
||||
- Search / filter bar inside playlist detail and favorites (instant, client-side)
|
||||
- Synced and plain lyrics via LRCLIB (auto-fetched per track)
|
||||
- Play count display — optional toggle in Settings
|
||||
- Star and context menu on every song 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.
|
||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
|
||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
|
||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
|
||||
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
|
||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||
|
||||
### Artist Pages
|
||||
- Full-bleed hero card — artist photo as portrait + blurred background (desktop); full-bleed image with name overlay (mobile)
|
||||
- Bio, similar artists, top songs pulled from Last.fm via Navidrome
|
||||
- Concert events via Ticketmaster (upcoming shows with venue, date, ticket link)
|
||||
- Albums cross-referenced against iTunes catalog — shows what's downloaded, partially downloaded, or missing
|
||||
- Quick actions: play top songs, shuffle all, open concerts
|
||||
|
||||
### Playback
|
||||
- Queue management with shuffle, repeat (all / one), and radio mode (endless random)
|
||||
- Spotify-style shuffle — pre-shuffles the full queue so every song plays exactly once
|
||||
- Now Playing overlay with swipe-to-dismiss
|
||||
- Gapless-style playback — next track pre-buffered 60 seconds before current track ends
|
||||
- Lock screen / notification media controls (Android / iOS / desktop)
|
||||
- Scrobbling — reported to Navidrome (configurable: 15 s / 30 s / ⅓ / ⅔ / end)
|
||||
|
||||
### Discovery
|
||||
- iTunes / Last.fm integration — charts, global search, 30-second previews, similar artists, recommendations
|
||||
- Song Importer — paste a Spotify, YouTube, YouTube Music, or SoundCloud link to preview and download tracks
|
||||
|
||||
### Downloads (yt-dlp backend)
|
||||
- Download individual tracks, full albums, or entire artists
|
||||
- Sources: YouTube Music (primary), YouTube, SoundCloud, Spotify (via importer)
|
||||
- iTunes enrichment — album, year, track number, genre, 1000×1000 cover art
|
||||
- Duplicate detection — skips tracks already present on the server
|
||||
- Standard filename: `01 - Artist - Title.ext`, organised into `AlbumArtist/Album/` folders
|
||||
|
||||
### Metadata Editor
|
||||
- Edit title, artist, album artist, album, genre, year, track number, disc number
|
||||
- Update embedded cover art via URL or file upload
|
||||
- File renamed and moved automatically on save
|
||||
- Navidrome rescan triggered after every save
|
||||
|
||||
### Playlists
|
||||
- Create, edit, and delete playlists
|
||||
- Edit name, description, cover art, public/private toggle
|
||||
- Add / remove tracks; reorder via drag and drop
|
||||
|
||||
### Audiobookshelf Integration
|
||||
- Browse and stream audiobooks from your Audiobookshelf server
|
||||
- Chapters list, playback speed, sleep timer, progress sync
|
||||
|
||||
### App
|
||||
- PWA — installable on Android / iOS / desktop
|
||||
- Android APK — native WebView wrapper with Media3 / ExoPlayer backend
|
||||
- Native OS notification with skip buttons
|
||||
- Pre-buffers last played track on cold start for instant resume
|
||||
- Artwork pre-warming so notification never shows blank cover
|
||||
- Multiple color themes, dark mode, mobile-first responsive layout
|
||||
- German UI
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,12 +29,13 @@ NovaSonic/
|
||||
├── src/
|
||||
│ ├── player/
|
||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
||||
│ │ └── nowplaying.js # Now Playing overlay
|
||||
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
|
||||
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
|
||||
│ ├── views/
|
||||
│ │ ├── detail.js # Artist / album detail pages
|
||||
│ │ ├── views.js # Navigation, page loaders, search
|
||||
│ │ ├── events.js # Ticketmaster concert events
|
||||
│ │ ├── home.js # Home page, artist image cache
|
||||
│ │ ├── home.js # Home page
|
||||
│ │ ├── search.js # Search, artist/album navigation
|
||||
│ │ ├── playlists.js # Playlist management
|
||||
│ │ ├── spotify.js # Spotify importer
|
||||
@@ -89,28 +44,41 @@ NovaSonic/
|
||||
│ │ ├── navigation.js # Client-side router
|
||||
│ │ └── abs.js # Audiobookshelf integration
|
||||
│ ├── ui/
|
||||
│ │ ├── ui.js # Modals, sidebar, settings, context menu
|
||||
│ │ ├── ui.js # Modals, sidebar, context menu
|
||||
│ │ ├── components.js # Song rows, cards, shared UI
|
||||
│ │ ├── downloads.js # Download modal + job tracker
|
||||
│ │ ├── metadata.js # Metadata editor
|
||||
│ │ ├── modals.js # Song info, events modals
|
||||
│ │ ├── notify.js # Toast notifications
|
||||
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||
│ │ └── settings.js # Settings page
|
||||
│ ├── state.js # Shared mutable state (S object)
|
||||
│ ├── utils.js # md5, esc, formatDur, toast
|
||||
│ ├── api.js # Subsonic API, iTunes API, cover art
|
||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||
│ ├── api.js # Subsonic API, Deezer API, cover art
|
||||
│ └── main.js # App init, audio wiring, window globals
|
||||
├── backend/
|
||||
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy
|
||||
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
||||
│ └── lib/
|
||||
│ ├── config.js # Env var → config object
|
||||
│ ├── jobs.js # In-memory download job store
|
||||
│ ├── metadata.js # iTunes lookup, artwork, lyrics, filename helpers
|
||||
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
|
||||
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
|
||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||
│ ├── squid.js # Qobuz (squid.wtf) download backend
|
||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||
│ └── romaji.js # Japanese → romaji search index
|
||||
├── android/ # Android APK (WebView + Media3/ExoPlayer)
|
||||
├── scrobble/ # Isolated listening-stats service (own container + DB)
|
||||
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
|
||||
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
|
||||
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||
│ ├── ui/
|
||||
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
||||
│ │ ├── component/ # MiniPlayer, SongRow, SongContextSheet, …
|
||||
│ │ └── theme/ # Color, Type, Theme
|
||||
│ └── viewmodel/ # ViewModel per screen
|
||||
├── public/
|
||||
│ ├── sw.js # Service worker
|
||||
│ ├── manifest.json # PWA manifest
|
||||
@@ -121,7 +89,7 @@ NovaSonic/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── nginx.conf
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL at runtime
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||
```
|
||||
|
||||
---
|
||||
@@ -141,16 +109,14 @@ Edit `.env`:
|
||||
```env
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||
|
||||
MUSIC_PATH=/srv/navidrome/music # absolute path to your music folder on the host
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best
|
||||
AUDIO_QUALITY=0 # 0 = best (yt-dlp)
|
||||
MAX_PARALLEL=2
|
||||
|
||||
DOWNLOAD_BACKEND=auto # ytdlp | squid | auto
|
||||
SQUID_QUALITY=mp3 # mp3 | flac | hi-res
|
||||
|
||||
TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
```
|
||||
|
||||
@@ -160,13 +126,15 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts both the frontend (nginx on port 8080) and the backend (Node.js on port 3700). Route both ports 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
|
||||
|
||||
1. Open the app and enter your Navidrome server URL, username and password
|
||||
2. Optionally add a Last.fm API key in Settings for recommendations and artist bios
|
||||
3. Optionally connect Audiobookshelf in Settings to stream audiobooks
|
||||
1. Enter your Navidrome server URL, username, and password
|
||||
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||
3. Optionally connect Audiobookshelf in Settings
|
||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
||||
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
|
||||
|
||||
---
|
||||
|
||||
@@ -178,19 +146,26 @@ This starts both the frontend (nginx on port 8080) and the backend (Node.js on p
|
||||
|---|---|
|
||||
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
||||
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
|
||||
|
||||
### Backend container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3700` | Port the backend listens on |
|
||||
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder (mounted as volume) |
|
||||
| `AUDIO_FMT` | `mp3` | Download output format (`mp3`, `flac`, `opus`, `best`) |
|
||||
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder |
|
||||
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
|
||||
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = best) |
|
||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||
| `DOWNLOAD_BACKEND` | `auto` | `ytdlp` / `squid` / `auto` (tries Qobuz first, falls back to yt-dlp) |
|
||||
| `SQUID_QUALITY` | `mp3` | Qobuz quality: `mp3`, `flac`, `hi-res` |
|
||||
| `TICKETMASTER_API_KEY` | — | Free API key from [developer.ticketmaster.com](https://developer.ticketmaster.com) — enables concert events on artist pages |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -87,12 +87,21 @@ dependencies {
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Palette (dominant cover color extraction in NowPlaying)
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
|
||||
// MediaStyle for ABS notification (already transitive via media3-session, made explicit)
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
|
||||
// Google Fonts (Outfit)
|
||||
implementation(libs.compose.ui.google.fonts)
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<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.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
@@ -43,6 +45,18 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
|
||||
<!-- FileProvider — hands the downloaded update APK to the system installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
|
||||
@@ -2,7 +2,14 @@ package com.novasonic.app
|
||||
|
||||
import android.app.Application
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PreviewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NovaSonicApp : Application() {
|
||||
|
||||
@@ -10,12 +17,24 @@ class NovaSonicApp : Application() {
|
||||
private set
|
||||
lateinit var playerController: PlayerController
|
||||
private set
|
||||
lateinit var absPlayerController: AbsPlayerController
|
||||
private set
|
||||
lateinit var offlineRepository: OfflineRepository
|
||||
private set
|
||||
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
|
||||
val previewController: PreviewController = PreviewController()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
absPlayerController = AbsPlayerController(applicationContext)
|
||||
// Flush any plays queued while offline to the scrobble service.
|
||||
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
runCatching { repository.flushPlayLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
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.HttpResponse
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class AbsLibrary(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val mediaType: String = "book",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsLibrariesResponse(val libraries: List<AbsLibrary> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class AbsMediaMetadata(
|
||||
val title: String? = null,
|
||||
val authorName: String? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsMedia(
|
||||
val metadata: AbsMediaMetadata = AbsMediaMetadata(),
|
||||
val duration: Double = 0.0,
|
||||
val numFiles: Int = 0,
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsLibraryItem(
|
||||
val id: String = "",
|
||||
val media: AbsMedia = AbsMedia(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsItemsResponse(
|
||||
val results: List<AbsLibraryItem> = emptyList(),
|
||||
val total: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsSearchBookResult(
|
||||
val libraryItem: AbsLibraryItem = AbsLibraryItem(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsSearchResponse(
|
||||
val book: List<AbsSearchBookResult> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsChapter(
|
||||
val id: Int = 0,
|
||||
val start: Double = 0.0,
|
||||
val end: Double = 0.0,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsAudioTrack(
|
||||
val index: Int = 0,
|
||||
val startOffset: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val contentUrl: String = "",
|
||||
val mimeType: String = "audio/mpeg",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsPlaySession(
|
||||
val id: String = "",
|
||||
val audioTracks: List<AbsAudioTrack> = emptyList(),
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
val currentTime: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsProgress(
|
||||
val currentTime: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val progress: Double = 0.0,
|
||||
val isFinished: Boolean = false,
|
||||
)
|
||||
|
||||
class AbsClient(
|
||||
val absUrl: String,
|
||||
val apiKey: String,
|
||||
private val backendUrl: String = "",
|
||||
) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 30_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
private suspend fun apiGet(path: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.get("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
}
|
||||
} else {
|
||||
http.get("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiPost(path: String, jsonBody: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.post("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
} else {
|
||||
http.post("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiPatch(path: String, jsonBody: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.patch("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
} else {
|
||||
http.patch("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ping(): Boolean = runCatching { apiGet("/api/me").status == HttpStatusCode.OK }.getOrDefault(false)
|
||||
|
||||
suspend fun getLibraries(): List<AbsLibrary> = runCatching {
|
||||
apiGet("/api/libraries").body<AbsLibrariesResponse>().libraries
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching {
|
||||
apiGet("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1")
|
||||
.body<AbsItemsResponse>()
|
||||
}.getOrDefault(AbsItemsResponse())
|
||||
|
||||
suspend fun searchLibraryItems(libId: String, query: String): List<AbsLibraryItem> = runCatching {
|
||||
val q = java.net.URLEncoder.encode(query.trim(), "UTF-8")
|
||||
apiGet("/api/libraries/$libId/search?q=$q&limit=40")
|
||||
.body<AbsSearchResponse>().book.map { it.libraryItem }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
|
||||
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun startSession(itemId: String): AbsPlaySession = runCatching {
|
||||
apiPost(
|
||||
"/api/items/$itemId/play",
|
||||
"""{"deviceInfo":{"clientName":"NovaSonic","clientVersion":"1.0"},"supportedMimeTypes":["audio/mpeg","audio/mp4","audio/aac","audio/ogg","audio/flac","audio/m4b"],"mediaPlayer":"html5","forceDirectPlay":true,"forceTranscode":false}"""
|
||||
).body<AbsPlaySession>()
|
||||
}.getOrThrow()
|
||||
|
||||
suspend fun closeSession(sessionId: String) {
|
||||
runCatching { apiPost("/api/session/$sessionId/close", "{}") }
|
||||
}
|
||||
|
||||
suspend fun getProgress(itemId: String): AbsProgress? = runCatching {
|
||||
apiGet("/api/me/progress/$itemId").body<AbsProgress>()
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun saveProgress(itemId: String, currentTime: Double, duration: Double) {
|
||||
val progress = if (duration > 0) (currentTime / duration).coerceAtMost(1.0) else 0.0
|
||||
val finished = progress >= 0.99
|
||||
runCatching {
|
||||
apiPatch(
|
||||
"/api/me/progress/$itemId",
|
||||
"""{"currentTime":$currentTime,"duration":$duration,"progress":$progress,"isFinished":$finished,"lastUpdate":${System.currentTimeMillis()}}"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun coverUrl(itemId: String): String =
|
||||
"$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
|
||||
|
||||
fun audioTrackUrl(contentUrl: String): String {
|
||||
val sep = if (contentUrl.contains('?')) "&" else "?"
|
||||
return "$absUrl$contentUrl${sep}token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Reads/writes the shared artist-pin overrides on the download backend
|
||||
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
|
||||
* The pins fix same-name artist collisions; see [ArtistOverride].
|
||||
*/
|
||||
class ArtistOverrideClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
@Serializable
|
||||
private data class SetReq(
|
||||
val ndArtistId: String,
|
||||
val deezerArtistId: String,
|
||||
val name: String,
|
||||
val image: String,
|
||||
val fanCount: Long?,
|
||||
)
|
||||
|
||||
/** Full map { ndArtistId -> override }. */
|
||||
suspend fun loadAll(): Map<String, ArtistOverride> =
|
||||
http.get("${base()}/api/artist-overrides").body()
|
||||
|
||||
/** Upsert a pin. Returns true on 2xx. */
|
||||
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
|
||||
}
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
/** Remove a pin. Returns true on 2xx. */
|
||||
suspend fun remove(ndArtistId: String): Boolean {
|
||||
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.novasonic.app.api.model.ConcertEvent
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
|
||||
object EventsClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
|
||||
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
|
||||
expectSuccess = true
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
|
||||
@@ -8,64 +8,107 @@ import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
|
||||
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
|
||||
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
|
||||
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
|
||||
* are kept for the same reason.
|
||||
*/
|
||||
object ItunesClient {
|
||||
|
||||
private const val BASE = "https://api.deezer.com"
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
// iTunes returns text/javascript instead of application/json — accept any content type
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
|
||||
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
|
||||
*/
|
||||
suspend fun search(
|
||||
query: String,
|
||||
entity: String = "musicTrack,album,musicArtist",
|
||||
limit: Int = 30,
|
||||
country: String = "us",
|
||||
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
|
||||
parameter("term", query)
|
||||
parameter("media", "music")
|
||||
parameter("entity", entity)
|
||||
parameter("limit", limit)
|
||||
parameter("country", country)
|
||||
): ItunesSearchResponse {
|
||||
if (entity == "album") {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
|
||||
parameter("q", query); parameter("limit", limit)
|
||||
}.body()
|
||||
|
||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", collectionId)
|
||||
parameter("entity", "song")
|
||||
}.body()
|
||||
// First result is the album itself; rest are tracks
|
||||
return resp.results.filter { it.kind == "song" }
|
||||
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
|
||||
}
|
||||
return coroutineScope {
|
||||
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
|
||||
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
|
||||
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
|
||||
// Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision
|
||||
// (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62).
|
||||
val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } +
|
||||
albumsD.await().map { it.toItunesItem() } +
|
||||
tracksD.await().map { it.toItunesItem() }
|
||||
ItunesSearchResponse(results = results)
|
||||
}
|
||||
}
|
||||
|
||||
/** All albums for an iTunes artist */
|
||||
/** All tracks of a Deezer album, in the ItunesItem song shape. */
|
||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
|
||||
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
||||
}
|
||||
|
||||
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
|
||||
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
|
||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "album")
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
||||
parameter("limit", 200)
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "collection" }
|
||||
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() }
|
||||
}
|
||||
|
||||
/** Worldwide top tracks for an iTunes artist */
|
||||
/**
|
||||
* 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. */
|
||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
|
||||
parameter("limit", limit + 1)
|
||||
parameter("explicit", "Yes")
|
||||
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
|
||||
return tracks.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/** Top albums RSS feed for a given country */
|
||||
/**
|
||||
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
|
||||
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
|
||||
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
|
||||
*/
|
||||
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
|
||||
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return artists.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
|
||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||
val resp: ItunesRssResponse =
|
||||
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
|
||||
return resp.feed?.entry ?: emptyList()
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return albums.data.map { it.toRssEntry() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LrcLibResponse(
|
||||
val id: Long = 0,
|
||||
val trackName: String = "",
|
||||
val artistName: String = "",
|
||||
val albumName: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val instrumental: Boolean = false,
|
||||
val plainLyrics: String? = null,
|
||||
val syncedLyrics: String? = null,
|
||||
)
|
||||
|
||||
|
||||
data class LrcLine(val timeMs: Long, val text: String)
|
||||
|
||||
fun parseLrc(lrc: String): List<LrcLine> {
|
||||
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
|
||||
return lrc.lines()
|
||||
.mapNotNull { line ->
|
||||
regex.find(line.trim())?.let { m ->
|
||||
val mm = m.groupValues[1].toLong()
|
||||
val ss = m.groupValues[2].toLong()
|
||||
val frac = m.groupValues[3]
|
||||
val ms = mm * 60_000 + ss * 1_000 +
|
||||
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
|
||||
val text = m.groupValues[4].trim()
|
||||
if (text.isNotEmpty()) LrcLine(ms, text) else null
|
||||
}
|
||||
}
|
||||
.sortedBy { it.timeMs }
|
||||
}
|
||||
|
||||
object LrcLibClient {
|
||||
private val client = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; isLenient = true })
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 8_000
|
||||
connectTimeoutMillis = 5_000
|
||||
socketTimeoutMillis = 8_000
|
||||
}
|
||||
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
|
||||
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
|
||||
}
|
||||
|
||||
private val cache = HashMap<String, LrcLibResponse?>()
|
||||
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
|
||||
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
suspend fun getLyrics(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
): LrcLibResponse? {
|
||||
val cacheKey = "$artist|$title"
|
||||
if (cache.containsKey(cacheKey)) return cache[cacheKey]
|
||||
val mutex = locks.getOrPut(cacheKey) { Mutex() }
|
||||
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
|
||||
}
|
||||
|
||||
private suspend fun getLyricsInner(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
cacheKey: String,
|
||||
): LrcLibResponse? {
|
||||
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
|
||||
|
||||
// 1a. lrclib exact match with duration
|
||||
val exact = runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/get") {
|
||||
parameter("artist_name", artist)
|
||||
parameter("track_name", title)
|
||||
if (!album.isNullOrBlank()) parameter("album_name", album)
|
||||
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
|
||||
}.getOrNull()
|
||||
if (exact.hasContent()) return exact.also { cache[cacheKey] = it }
|
||||
|
||||
// 1b. lrclib exact match without duration — handles version mismatches (e.g. 5:58 file vs 4:24 in lrclib)
|
||||
val exactNoDur = if (durationSecs != null && !album.isNullOrBlank()) runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/get") {
|
||||
parameter("artist_name", artist)
|
||||
parameter("track_name", title)
|
||||
parameter("album_name", album)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
|
||||
}.getOrNull() else null
|
||||
if (exactNoDur.hasContent()) return exactNoDur.also { cache[cacheKey] = it }
|
||||
|
||||
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
|
||||
val lrclibResult = coroutineScope {
|
||||
val artistDef = async {
|
||||
runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/search") {
|
||||
parameter("track_name", title)
|
||||
if (artist.isNotBlank()) parameter("artist_name", artist)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
|
||||
}.getOrNull()
|
||||
}
|
||||
val titleDef = async {
|
||||
runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/search") {
|
||||
parameter("track_name", title)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
|
||||
}.getOrNull()
|
||||
}
|
||||
bestMatch(artistDef.await(), durationSecs)
|
||||
?: bestMatch(titleDef.await(), durationSecs)
|
||||
}
|
||||
|
||||
cache[cacheKey] = lrclibResult
|
||||
return lrclibResult
|
||||
}
|
||||
|
||||
private fun LrcLibResponse?.hasContent() =
|
||||
this != null && (instrumental || !syncedLyrics.isNullOrBlank() || !plainLyrics.isNullOrBlank())
|
||||
|
||||
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
|
||||
if (candidates.isNullOrEmpty()) return null
|
||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||
val pool = synced.ifEmpty { candidates }
|
||||
return if (durationSecs != null && durationSecs > 0) {
|
||||
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
||||
?: return synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
|
||||
else synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
} else {
|
||||
pool.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
@@ -157,10 +160,6 @@ class SubsonicClient(
|
||||
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getLyrics(songId: String): List<com.novasonic.app.api.model.StructuredLyrics> =
|
||||
get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics
|
||||
?: emptyList()
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
@@ -171,6 +170,58 @@ class SubsonicClient(
|
||||
?: throw SubsonicException(-1, "Failed to create playlist")
|
||||
}
|
||||
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) =
|
||||
getList("updatePlaylist.view", listOfNotNull(
|
||||
"playlistId" to playlistId,
|
||||
"name" to name,
|
||||
comment?.let { "comment" to it },
|
||||
))
|
||||
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) =
|
||||
getList("updatePlaylist.view",
|
||||
listOf("playlistId" to playlistId) + indices.map { "songIndexToRemove" to it.toString() })
|
||||
|
||||
suspend fun deletePlaylist(playlistId: String) =
|
||||
getList("deletePlaylist.view", listOf("id" to playlistId))
|
||||
|
||||
// ── Navidrome REST API (cover art) ────────────────────────────────────────
|
||||
|
||||
@Serializable private data class NdLoginReq(val username: String, val password: String)
|
||||
@Serializable private data class NdLoginResp(val token: String = "")
|
||||
|
||||
private var _ndToken: String? = null
|
||||
|
||||
private suspend fun ndToken(): String {
|
||||
_ndToken?.let { return it }
|
||||
val resp = http.post("$serverUrl/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(NdLoginReq(username, password))
|
||||
}.body<NdLoginResp>()
|
||||
val token = resp.token.ifBlank { throw Exception("Navidrome login fehlgeschlagen") }
|
||||
_ndToken = token
|
||||
return token
|
||||
}
|
||||
|
||||
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) {
|
||||
val token = ndToken()
|
||||
http.post("$serverUrl/api/playlist/$playlistId/image") {
|
||||
header("X-Nd-Authorization", "Bearer $token")
|
||||
setBody(MultiPartFormDataContent(formData {
|
||||
append("image", imageBytes, Headers.build {
|
||||
append(HttpHeaders.ContentType, mimeType)
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"cover\"")
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removePlaylistCover(playlistId: String) {
|
||||
val token = ndToken()
|
||||
http.delete("$serverUrl/api/playlist/$playlistId/image") {
|
||||
header("X-Nd-Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
|
||||
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
|
||||
|
||||
@@ -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,131 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
|
||||
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
|
||||
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
|
||||
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
|
||||
// unchanged. See ItunesClient for the endpoint calls.
|
||||
|
||||
@Serializable
|
||||
data class DzList<T>(val data: List<T> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class DzArtist(
|
||||
val id: Long? = null,
|
||||
val name: String? = null,
|
||||
val picture_medium: String? = null,
|
||||
val picture_big: String? = null,
|
||||
val picture_xl: String? = null,
|
||||
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DzGenre(val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class DzGenres(val data: List<DzGenre> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class DzAlbum(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
val cover_medium: String? = null,
|
||||
val cover_big: String? = null,
|
||||
val cover_xl: String? = null,
|
||||
val release_date: String? = null,
|
||||
val nb_tracks: Int? = null,
|
||||
val record_type: String? = null,
|
||||
val genres: DzGenres? = null,
|
||||
val artist: DzArtist? = null,
|
||||
val tracks: DzList<DzTrack>? = null,
|
||||
val position: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DzTrack(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
val duration: Long? = null,
|
||||
val track_position: Int? = null,
|
||||
val disk_number: Int? = null,
|
||||
val explicit_lyrics: Boolean = false,
|
||||
val preview: String? = null,
|
||||
val artist: DzArtist? = null,
|
||||
val album: DzAlbum? = null,
|
||||
)
|
||||
|
||||
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
|
||||
|
||||
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
|
||||
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
|
||||
|
||||
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
|
||||
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "track",
|
||||
kind = "song",
|
||||
trackId = id,
|
||||
artistId = artist?.id,
|
||||
collectionId = album?.id,
|
||||
artistName = artist?.name,
|
||||
collectionName = album?.title,
|
||||
trackName = title,
|
||||
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
|
||||
previewUrl = preview,
|
||||
trackTimeMillis = duration?.let { it * 1000 },
|
||||
trackNumber = track_position,
|
||||
)
|
||||
|
||||
/** A Deezer album → ItunesItem collection. */
|
||||
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "collection",
|
||||
kind = "album",
|
||||
collectionId = id,
|
||||
artistId = artist?.id,
|
||||
artistName = artist?.name,
|
||||
collectionName = title,
|
||||
artworkUrl100 = coverXl(),
|
||||
trackCount = nb_tracks,
|
||||
releaseDate = release_date,
|
||||
primaryGenreName = genres?.data?.firstOrNull()?.name,
|
||||
)
|
||||
|
||||
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
|
||||
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "artist",
|
||||
artistId = id,
|
||||
artistName = name,
|
||||
artworkUrl100 = pictureXl(),
|
||||
fanCount = nb_fan,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
|
||||
* track_position, so the album ordering supplies the number; genre/release come from the album.
|
||||
*/
|
||||
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
|
||||
wrapperType = "track",
|
||||
kind = "song",
|
||||
trackId = id,
|
||||
artistId = artist?.id ?: album.artist?.id,
|
||||
collectionId = album.id,
|
||||
artistName = artist?.name ?: album.artist?.name,
|
||||
collectionName = album.title,
|
||||
trackName = title,
|
||||
artworkUrl100 = album.coverXl(),
|
||||
previewUrl = preview,
|
||||
trackTimeMillis = duration?.let { it * 1000 },
|
||||
trackNumber = track_position ?: (index + 1),
|
||||
trackCount = album.nb_tracks,
|
||||
releaseDate = album.release_date,
|
||||
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
|
||||
)
|
||||
|
||||
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
|
||||
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
|
||||
name = ItunesLabel(title),
|
||||
artist = ItunesLabel(artist?.name),
|
||||
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
|
||||
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
|
||||
)
|
||||
@@ -27,8 +27,18 @@ data class ItunesItem(
|
||||
val trackCount: Int? = null,
|
||||
val trackNumber: Int? = null,
|
||||
val collectionPrice: Double? = null,
|
||||
val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI
|
||||
)
|
||||
|
||||
// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA).
|
||||
fun fmtFanCount(n: Long): String {
|
||||
if (n >= 1_000_000) {
|
||||
val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0")
|
||||
return "$s Mio."
|
||||
}
|
||||
return String.format(java.util.Locale.GERMAN, "%,d", n)
|
||||
}
|
||||
|
||||
// iTunes RSS feed response
|
||||
@Serializable
|
||||
data class ItunesRssResponse(
|
||||
|
||||
@@ -33,7 +33,6 @@ data class SubsonicResponse(
|
||||
val topSongs: SongListResult? = null,
|
||||
val genres: GenresResult? = null,
|
||||
val songsByGenre: SongListResult? = null,
|
||||
val lyricsList: LyricsListResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -184,19 +183,3 @@ data class Genre(
|
||||
@Serializable
|
||||
data class GenresResult(val genre: List<Genre> = emptyList())
|
||||
|
||||
// ── Lyrics ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class LyricsLine(val start: Long? = null, val value: String = "")
|
||||
|
||||
@Serializable
|
||||
data class StructuredLyrics(
|
||||
val lang: String = "",
|
||||
val synced: Boolean = false,
|
||||
val line: List<LyricsLine> = emptyList(),
|
||||
val displayArtist: String? = null,
|
||||
val displayTitle: String? = null,
|
||||
val offset: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LyricsListResult(val structuredLyrics: List<StructuredLyrics> = emptyList())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
|
||||
object ClientHolder {
|
||||
@Volatile private var client: SubsonicClient? = null
|
||||
@@ -28,6 +32,7 @@ object ClientHolder {
|
||||
class MusicRepository(private val context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
private val playLog = PlayLogClient(context)
|
||||
|
||||
private suspend fun api(): SubsonicClient =
|
||||
ClientHolder.get(prefs.credentials.first())
|
||||
@@ -52,24 +57,86 @@ class MusicRepository(private val context: Context) {
|
||||
suspend fun star(id: String) = api().star(id)
|
||||
suspend fun unstar(id: String) = api().unstar(id)
|
||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
|
||||
suspend fun logPlay(song: Song) = playLog.logPlay(song)
|
||||
suspend fun flushPlayLog() = playLog.flush()
|
||||
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
||||
playLog.fetchStats(from, to, tz, bucket, limit)
|
||||
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
||||
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
||||
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
|
||||
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
|
||||
|
||||
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
|
||||
suspend fun artistOverrides(): Map<String, ArtistOverride> {
|
||||
_artistOverrides?.let { return it }
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
|
||||
c.close()
|
||||
return m.also { _artistOverrides = it }
|
||||
}
|
||||
|
||||
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
|
||||
|
||||
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun getLyrics(songId: String) = api().getLyrics(songId)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
||||
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
||||
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) = api().uploadPlaylistCover(playlistId, imageBytes, mimeType)
|
||||
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
|
||||
|
||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
||||
if (id != null) {
|
||||
offlineRepo()?.localCoverPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
.coverArtUrl(id ?: return null, size)
|
||||
// Reuse the cached client — building a fresh SubsonicClient here spins up a whole Ktor
|
||||
// HttpClient per cover (and never closes it), which is heavy GC/alloc churn while scrolling.
|
||||
return ClientHolder.get(creds).coverArtUrl(id ?: return null, size)
|
||||
}
|
||||
|
||||
fun streamUrl(id: String): String? {
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
||||
offlineRepo()?.localAudioPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
return ClientHolder.get(creds).streamUrl(id) // reuse cached client (no per-call HttpClient)
|
||||
}
|
||||
|
||||
private fun offlineRepo(): OfflineRepository? = runCatching {
|
||||
(context.applicationContext as NovaSonicApp).offlineRepository
|
||||
}.getOrNull()
|
||||
|
||||
var lastCredentials: PrefsStore.Credentials? = null
|
||||
|
||||
@@ -101,6 +168,17 @@ class MusicRepository(private val context: Context) {
|
||||
|
||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||
|
||||
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
||||
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
||||
|
||||
fun lastPlayedSongFlow() = prefs.lastPlayedSong
|
||||
|
||||
suspend fun absClient(): com.novasonic.app.api.AbsClient? {
|
||||
val s = prefs.appSettings.first()
|
||||
if (s.absUrl.isBlank() || s.absApiKey.isBlank()) return null
|
||||
return com.novasonic.app.api.AbsClient(s.absUrl.trimEnd('/'), s.absApiKey, s.backendUrl.trimEnd('/'))
|
||||
}
|
||||
|
||||
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
|
||||
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
||||
settings.username != lastCredentials?.username ||
|
||||
|
||||
@@ -16,6 +16,11 @@ data class AppNotification(
|
||||
val message: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val read: Boolean = false,
|
||||
// Navigation — mirrors web notify.js fields
|
||||
val navType: String = "", // "track" | "album" | "playlist"
|
||||
val navAlbum: String = "",
|
||||
val navArtist: String = "",
|
||||
val navId: String = "", // playlist ID if known
|
||||
)
|
||||
|
||||
object NotificationStore {
|
||||
@@ -44,12 +49,16 @@ object NotificationStore {
|
||||
jobId: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
navType: String = "",
|
||||
navAlbum: String = "",
|
||||
navArtist: String = "",
|
||||
navId: String = "",
|
||||
) {
|
||||
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
|
||||
scope.launch {
|
||||
val client = ImporterClient(backendUrl)
|
||||
try {
|
||||
repeat(240) { // up to ~6 minutes at 1.5 s interval
|
||||
repeat(400) { // up to ~10 minutes at 1.5 s interval
|
||||
delay(1500)
|
||||
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
|
||||
?: return@repeat
|
||||
@@ -58,6 +67,8 @@ object NotificationStore {
|
||||
replace(AppNotification(
|
||||
id = jobId, title = title, artist = artist,
|
||||
status = "done", message = st.message ?: "Fertig!",
|
||||
navType = navType, navAlbum = navAlbum,
|
||||
navArtist = navArtist, navId = navId,
|
||||
))
|
||||
return@launch
|
||||
}
|
||||
@@ -65,6 +76,8 @@ object NotificationStore {
|
||||
replace(AppNotification(
|
||||
id = jobId, title = title, artist = artist,
|
||||
status = "error", message = st.message ?: "Fehler",
|
||||
navType = navType, navAlbum = navAlbum,
|
||||
navArtist = navArtist, navId = navId,
|
||||
))
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.novasonic.app.data
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
@@ -18,9 +18,17 @@ class PrefsStore(private val context: Context) {
|
||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") // 15s|30s|third|two_thirds|end
|
||||
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||
val KEY_THEME = stringPreferencesKey("theme")
|
||||
val KEY_ABS_URL = stringPreferencesKey("abs_url")
|
||||
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
|
||||
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
||||
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
||||
}
|
||||
|
||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||
@@ -33,9 +41,20 @@ class PrefsStore(private val context: Context) {
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
)
|
||||
|
||||
data class LastPlayedSong(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val coverArtId: String?,
|
||||
)
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
@@ -53,9 +72,23 @@ class PrefsStore(private val context: Context) {
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
|
||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||
absUrl = prefs[KEY_ABS_URL] ?: "",
|
||||
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
|
||||
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
val lastPlayedSong: Flow<LastPlayedSong?> = context.dataStore.data.map { prefs ->
|
||||
val id = prefs[KEY_LAST_SONG_ID]?.takeIf { it.isNotBlank() } ?: return@map null
|
||||
LastPlayedSong(
|
||||
id = id,
|
||||
title = prefs[KEY_LAST_SONG_TITLE] ?: "",
|
||||
artist = prefs[KEY_LAST_SONG_ARTIST] ?: "",
|
||||
coverArtId = prefs[KEY_LAST_SONG_COVER]?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,9 +107,13 @@ class PrefsStore(private val context: Context) {
|
||||
prefs[KEY_PASSWORD] = settings.password
|
||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
|
||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||
prefs[KEY_THEME] = settings.theme
|
||||
prefs[KEY_ABS_URL] = settings.absUrl
|
||||
prefs[KEY_ABS_API_KEY] = settings.absApiKey
|
||||
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +121,15 @@ class PrefsStore(private val context: Context) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
|
||||
suspend fun saveLastSong(id: String, title: String, artist: String, coverArt: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_LAST_SONG_ID] = id
|
||||
prefs[KEY_LAST_SONG_TITLE] = title
|
||||
prefs[KEY_LAST_SONG_ARTIST] = artist
|
||||
prefs[KEY_LAST_SONG_COVER] = coverArt ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import com.novasonic.app.api.model.Song
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately*
|
||||
* (optimistic), so every screen showing that song updates at once — regardless of when each
|
||||
* screen last fetched `song.starred`. The API call runs in the background and reverts on failure.
|
||||
*/
|
||||
object StarState {
|
||||
private val _overrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val overrides: StateFlow<Map<String, Boolean>> = _overrides.asStateFlow()
|
||||
|
||||
/** Current starred state for a song: the live override if present, else the song's own flag. */
|
||||
fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null)
|
||||
fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback
|
||||
|
||||
/** Optimistically toggle, then hit the API; revert the override if the call fails. */
|
||||
fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) {
|
||||
val current = _overrides.value[songId] ?: fallbackStarred
|
||||
val next = !current
|
||||
_overrides.value = _overrides.value + (songId to next)
|
||||
scope.launch {
|
||||
val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess
|
||||
if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class DownloadWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_GROUP_ID = "group_id"
|
||||
}
|
||||
|
||||
private val offlineRepo = applicationContext.app.offlineRepository
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
||||
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
val songIds = offlineRepo.songIdsForGroup(groupId)
|
||||
if (songIds.isEmpty()) return Result.success()
|
||||
|
||||
val creds = PrefsStore(applicationContext).credentials.first()
|
||||
if (!creds.isValid) return Result.failure()
|
||||
|
||||
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
var done = group.downloadedTracks
|
||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||
|
||||
return try {
|
||||
for (songId in songIds) {
|
||||
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||
done++
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
continue
|
||||
}
|
||||
|
||||
val track = offlineRepo.getTrack(songId) ?: continue
|
||||
dao.updateState(songId, DownloadState.DOWNLOADING, 0f)
|
||||
|
||||
val suffix = track.suffix?.takeIf { it.isNotBlank() } ?: "mp3"
|
||||
val audioFile = offlineRepo.audioFile(songId, suffix)
|
||||
|
||||
try {
|
||||
streamToFile(subsonicClient.streamUrl(songId), audioFile)
|
||||
|
||||
val coverPath = track.coverArt?.let { coverArtId ->
|
||||
val existing = offlineRepo.localCoverPath(coverArtId)
|
||||
if (existing != null && File(existing).exists()) {
|
||||
existing
|
||||
} else {
|
||||
val coverFile = offlineRepo.coverFile(coverArtId)
|
||||
runCatching { streamToFile(subsonicClient.coverArtUrl(coverArtId, 512), coverFile) }
|
||||
if (coverFile.exists()) coverFile.absolutePath else null
|
||||
}
|
||||
}
|
||||
|
||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||
done++
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Failed to download song $songId", e)
|
||||
audioFile.delete()
|
||||
offlineRepo.markFailed(songId)
|
||||
}
|
||||
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamToFile(url: String, dest: File) = withContext(Dispatchers.IO) {
|
||||
val tmp = File("${dest.absolutePath}.tmp")
|
||||
try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.connect()
|
||||
if (conn.responseCode !in 200..299) {
|
||||
throw Exception("HTTP ${conn.responseCode} for ${dest.name}")
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(tmp).use { out ->
|
||||
val buf = ByteArray(32_768)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } >= 0) {
|
||||
out.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tmp.renameTo(dest)) {
|
||||
tmp.copyTo(dest, overwrite = true)
|
||||
tmp.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tmp.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface OfflineDao {
|
||||
|
||||
// ── Tracks ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTrack(track: DownloadedTrackEntity)
|
||||
|
||||
@Query("UPDATE downloaded_tracks SET state = :state, progress = :progress WHERE songId = :songId")
|
||||
suspend fun updateState(songId: String, state: DownloadState, progress: Float)
|
||||
|
||||
@Query("UPDATE downloaded_tracks SET state = 'DONE', progress = 1, localPath = :localPath, coverPath = :coverPath, downloadedAt = :at WHERE songId = :songId")
|
||||
suspend fun markDone(songId: String, localPath: String, coverPath: String?, at: Long)
|
||||
|
||||
/** Emits whenever any downloaded track changes — drives the hot in-memory maps. */
|
||||
@Query("SELECT songId, localPath, coverArt, coverPath FROM downloaded_tracks WHERE state = 'DONE'")
|
||||
fun doneTracksFlow(): Flow<List<DoneTrackInfo>>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
|
||||
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun getTrackById(songId: String): DownloadedTrackEntity?
|
||||
|
||||
@Query("SELECT state FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun getTrackStateRaw(songId: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertTrackIfAbsent(track: DownloadedTrackEntity)
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
|
||||
fun allTracksFlow(): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM group_track_cross_refs WHERE songId = :songId")
|
||||
suspend fun groupCountForSong(songId: String): Int
|
||||
|
||||
@Query("DELETE FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun deleteTrackById(songId: String)
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGroup(group: DownloadGroupEntity)
|
||||
|
||||
@Query("SELECT * FROM download_groups ORDER BY createdAt DESC")
|
||||
fun allGroupsFlow(): Flow<List<DownloadGroupEntity>>
|
||||
|
||||
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
||||
|
||||
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun deleteGroup(groupId: String)
|
||||
|
||||
@Query("UPDATE download_groups SET downloadedTracks = :count WHERE groupId = :groupId")
|
||||
suspend fun updateGroupProgress(groupId: String, count: Int)
|
||||
|
||||
@Query("UPDATE download_groups SET totalTracks = :total WHERE groupId = :groupId")
|
||||
suspend fun updateGroupTotal(groupId: String, total: Int)
|
||||
|
||||
// ── Cross-refs ────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertCrossRef(ref: GroupTrackCrossRef)
|
||||
|
||||
@Query("SELECT songId FROM group_track_cross_refs WHERE groupId = :groupId")
|
||||
suspend fun songIdsForGroup(groupId: String): List<String>
|
||||
|
||||
@Query("""
|
||||
SELECT t.* FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId
|
||||
""")
|
||||
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT t.* FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId
|
||||
""")
|
||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||
|
||||
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
|
||||
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
|
||||
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@TypeConverters(OfflineConverters::class)
|
||||
@Database(
|
||||
entities = [
|
||||
DownloadedTrackEntity::class,
|
||||
DownloadGroupEntity::class,
|
||||
GroupTrackCrossRef::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class OfflineDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun offlineDao(): OfflineDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: OfflineDatabase? = null
|
||||
|
||||
fun get(context: Context): OfflineDatabase = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
OfflineDatabase::class.java,
|
||||
"novasonic_offline.db",
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OfflineConverters {
|
||||
@TypeConverter fun fromState(v: DownloadState): String = v.name
|
||||
@TypeConverter fun toState(v: String): DownloadState = DownloadState.valueOf(v)
|
||||
@TypeConverter fun fromType(v: GroupType): String = v.name
|
||||
@TypeConverter fun toType(v: String): GroupType = GroupType.valueOf(v)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import androidx.room.*
|
||||
import com.novasonic.app.api.model.Song
|
||||
|
||||
enum class DownloadState { QUEUED, DOWNLOADING, DONE, FAILED }
|
||||
enum class GroupType { SONG, ALBUM, PLAYLIST, FAVORITES }
|
||||
|
||||
/** Maps a stored offline track back to a domain [Song] for playback / list display. */
|
||||
fun DownloadedTrackEntity.toSong() = Song(
|
||||
id = songId,
|
||||
title = title,
|
||||
album = album,
|
||||
albumId = albumId,
|
||||
artist = artist,
|
||||
artistId = artistId,
|
||||
track = track,
|
||||
year = year,
|
||||
genre = genre,
|
||||
coverArt = coverArt,
|
||||
duration = duration,
|
||||
suffix = suffix,
|
||||
contentType = contentType,
|
||||
bitRate = bitRate,
|
||||
starred = starred,
|
||||
)
|
||||
|
||||
/** Lightweight projection used to keep the hot in-memory maps up to date. */
|
||||
data class DoneTrackInfo(
|
||||
val songId: String,
|
||||
val localPath: String,
|
||||
val coverArt: String?,
|
||||
val coverPath: String?,
|
||||
)
|
||||
|
||||
@Entity(tableName = "downloaded_tracks")
|
||||
data class DownloadedTrackEntity(
|
||||
@PrimaryKey val songId: String,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val artistId: String?,
|
||||
val album: String?,
|
||||
val albumId: String?,
|
||||
val coverArt: String?,
|
||||
val track: Int?,
|
||||
val year: Int?,
|
||||
val genre: String?,
|
||||
val duration: Int?,
|
||||
val suffix: String?,
|
||||
val contentType: String?,
|
||||
val bitRate: Int?,
|
||||
val starred: String?,
|
||||
val localPath: String,
|
||||
val coverPath: String?,
|
||||
val state: DownloadState,
|
||||
val progress: Float = 0f,
|
||||
val downloadedAt: Long = 0L,
|
||||
)
|
||||
|
||||
@Entity(tableName = "download_groups")
|
||||
data class DownloadGroupEntity(
|
||||
@PrimaryKey val groupId: String, // e.g. "album:abc123", "playlist:xyz", "favorites"
|
||||
val type: GroupType,
|
||||
val sourceId: String,
|
||||
val name: String,
|
||||
val coverArt: String?,
|
||||
val totalTracks: Int = 0,
|
||||
val downloadedTracks: Int = 0,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "group_track_cross_refs",
|
||||
primaryKeys = ["groupId", "songId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DownloadGroupEntity::class,
|
||||
parentColumns = ["groupId"],
|
||||
childColumns = ["groupId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = DownloadedTrackEntity::class,
|
||||
parentColumns = ["songId"],
|
||||
childColumns = ["songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("songId")],
|
||||
)
|
||||
data class GroupTrackCrossRef(val groupId: String, val songId: String)
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.novasonic.app.api.model.Song
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.io.File
|
||||
|
||||
class OfflineRepository(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val dao = OfflineDatabase.get(appContext).offlineDao()
|
||||
private val scope = CoroutineScope(
|
||||
SupervisorJob() + Dispatchers.IO +
|
||||
CoroutineExceptionHandler { _, t -> Log.e("OfflineRepository", "Unhandled exception", t) }
|
||||
)
|
||||
|
||||
// ── Hot in-memory maps (no suspend needed for lookups) ────────────────────
|
||||
// songId → absolute local audio path
|
||||
private val _audioMap = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
// coverArtId (Navidrome) → absolute local cover path
|
||||
private val _coverMap = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
/** Reactive set of all downloaded song IDs — collect in UI for per-row indicators. */
|
||||
val downloadedIds: StateFlow<Set<String>> = _audioMap
|
||||
.map { it.keys.toSet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dao.doneTracksFlow().collect { tracks ->
|
||||
_audioMap.value = tracks.associate { it.songId to it.localPath }
|
||||
_coverMap.value = tracks
|
||||
.filter { it.coverArt != null && it.coverPath != null }
|
||||
.associate { it.coverArt!! to it.coverPath!! }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synchronous lookups (called from MusicRepository — no coroutine) ──────
|
||||
|
||||
fun isDownloaded(songId: String): Boolean = songId in _audioMap.value
|
||||
fun localAudioPath(songId: String): String? = _audioMap.value[songId]
|
||||
fun localCoverPath(coverArtId: String): String? = _coverMap.value[coverArtId]
|
||||
|
||||
// ── Storage layout ────────────────────────────────────────────────────────
|
||||
|
||||
fun audioDir(): File = File(appContext.filesDir, "offline/audio").also { it.mkdirs() }
|
||||
fun coversDir(): File = File(appContext.filesDir, "offline/covers").also { it.mkdirs() }
|
||||
|
||||
fun audioFile(songId: String, suffix: String): File = File(audioDir(), "$songId.$suffix")
|
||||
fun coverFile(coverArtId: String): File = File(coversDir(), "$coverArtId.jpg")
|
||||
|
||||
// ── Reactive flows for UI ─────────────────────────────────────────────────
|
||||
|
||||
val allGroupsFlow: Flow<List<DownloadGroupEntity>> = dao.allGroupsFlow()
|
||||
val allTracksFlow: Flow<List<DownloadedTrackEntity>> = dao.allTracksFlow()
|
||||
|
||||
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?> = dao.trackFlow(songId)
|
||||
|
||||
// ── Write operations (used by DownloadWorker — Phase 2) ──────────────────
|
||||
|
||||
suspend fun insertTrack(entity: DownloadedTrackEntity) = dao.insertTrack(entity)
|
||||
|
||||
suspend fun updateState(songId: String, state: DownloadState, progress: Float) =
|
||||
dao.updateState(songId, state, progress)
|
||||
|
||||
suspend fun markDone(songId: String, localPath: String, coverPath: String?) =
|
||||
dao.markDone(songId, localPath, coverPath, System.currentTimeMillis())
|
||||
|
||||
suspend fun markFailed(songId: String) =
|
||||
dao.updateState(songId, DownloadState.FAILED, 0f)
|
||||
|
||||
suspend fun insertGroup(group: DownloadGroupEntity) = dao.insertGroup(group)
|
||||
suspend fun getGroup(groupId: String) = dao.getGroup(groupId)
|
||||
suspend fun updateGroupProgress(groupId: String, count: Int) =
|
||||
dao.updateGroupProgress(groupId, count)
|
||||
suspend fun updateGroupTotal(groupId: String, total: Int) =
|
||||
dao.updateGroupTotal(groupId, total)
|
||||
|
||||
suspend fun insertCrossRef(groupId: String, songId: String) =
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, songId))
|
||||
|
||||
suspend fun songIdsForGroup(groupId: String): List<String> = dao.songIdsForGroup(groupId)
|
||||
|
||||
suspend fun getTrack(songId: String): DownloadedTrackEntity? = dao.getTrackById(songId)
|
||||
|
||||
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity> = dao.tracksForGroup(groupId)
|
||||
|
||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>> = dao.tracksForGroupFlow(groupId)
|
||||
|
||||
suspend fun getTrackState(songId: String): DownloadState? =
|
||||
dao.getTrackStateRaw(songId)?.let { DownloadState.valueOf(it) }
|
||||
|
||||
/**
|
||||
* Inserts all songs as QUEUED into Room, creates a download group, and enqueues a WorkManager
|
||||
* job that downloads the files in the background. Already-DONE tracks are counted but not
|
||||
* re-downloaded. Safe to call for an existing groupId — WorkManager KEEP policy is used.
|
||||
*/
|
||||
suspend fun enqueueGroup(
|
||||
type: GroupType,
|
||||
sourceId: String,
|
||||
name: String,
|
||||
coverArt: String?,
|
||||
songs: List<Song>,
|
||||
) {
|
||||
val groupId = "${type.name.lowercase()}:$sourceId"
|
||||
// Preserve creation time + progress when re-enqueuing (resume / re-download missing songs).
|
||||
val existing = dao.getGroup(groupId)
|
||||
dao.insertGroup(
|
||||
DownloadGroupEntity(
|
||||
groupId = groupId,
|
||||
type = type,
|
||||
sourceId = sourceId,
|
||||
name = name,
|
||||
coverArt = coverArt,
|
||||
totalTracks = songs.size,
|
||||
downloadedTracks = existing?.downloadedTracks ?: 0,
|
||||
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
var alreadyDone = 0
|
||||
for (song in songs) {
|
||||
if (isDownloaded(song.id)) {
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
|
||||
alreadyDone++
|
||||
} else {
|
||||
dao.insertTrackIfAbsent(
|
||||
DownloadedTrackEntity(
|
||||
songId = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artistId = song.artistId,
|
||||
album = song.album,
|
||||
albumId = song.albumId,
|
||||
coverArt = song.coverArt,
|
||||
track = song.track,
|
||||
year = song.year,
|
||||
genre = song.genre,
|
||||
duration = song.duration,
|
||||
suffix = song.suffix,
|
||||
contentType = song.contentType,
|
||||
bitRate = song.bitRate,
|
||||
starred = song.starred,
|
||||
localPath = "",
|
||||
coverPath = null,
|
||||
state = DownloadState.QUEUED,
|
||||
)
|
||||
)
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
|
||||
}
|
||||
}
|
||||
dao.updateGroupProgress(groupId, alreadyDone)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
// ── Delete with reference-count file cleanup ──────────────────────────────
|
||||
|
||||
/** Removes a group and any tracks that are no longer referenced by another group. */
|
||||
suspend fun deleteGroup(groupId: String) {
|
||||
val songIds = dao.songIdsForGroup(groupId)
|
||||
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
||||
for (songId in songIds) {
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
||||
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
||||
dao.removeCrossRef(groupId, songId)
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
recountGroup(groupId)
|
||||
}
|
||||
|
||||
/** Recomputes a group's total + downloaded counts from its cross-refs; deletes it if empty. */
|
||||
private suspend fun recountGroup(groupId: String) {
|
||||
val tracks = dao.tracksForGroup(groupId)
|
||||
if (tracks.isEmpty()) {
|
||||
dao.deleteGroup(groupId)
|
||||
return
|
||||
}
|
||||
dao.updateGroupTotal(groupId, tracks.size)
|
||||
dao.updateGroupProgress(groupId, tracks.count { it.state == DownloadState.DONE })
|
||||
}
|
||||
|
||||
// ── Storage accounting ────────────────────────────────────────────────────
|
||||
|
||||
fun storageUsedBytes(): Long =
|
||||
audioDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } +
|
||||
coversDir().walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.api.AbsChapter
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.api.AbsAudioTrack
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class AbsPlayState(
|
||||
val isLoading: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val item: AbsLibraryItem? = null,
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
val duration: Double = 0.0,
|
||||
val currentTime: Double = 0.0,
|
||||
val chapterIdx: Int = 0,
|
||||
val speed: Float = 1f,
|
||||
val sleepRemainingSecs: Int? = null,
|
||||
val chapterTrackMode: Boolean = false,
|
||||
val sessionId: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AbsPlayerController(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
var scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("AbsPlayerController", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private set
|
||||
|
||||
private val _state = MutableStateFlow(AbsPlayState())
|
||||
val state: StateFlow<AbsPlayState> = _state.asStateFlow()
|
||||
|
||||
val isActive: Boolean get() = _state.value.item != null
|
||||
|
||||
private var absClient: AbsClient? = null
|
||||
private var audioTracks: List<AbsAudioTrack> = emptyList()
|
||||
private var currentItemId: String? = null
|
||||
private var isClosed = false
|
||||
|
||||
private var positionObserverJob: Job? = null
|
||||
private var syncJob: Job? = null
|
||||
private var sleepJob: Job? = null
|
||||
private var sleepTickJob: Job? = null
|
||||
|
||||
fun currentCoverUrl(): String? {
|
||||
val item = _state.value.item ?: return null
|
||||
val base = absClient?.coverUrl(item.id) ?: return null
|
||||
return "$base&width=512"
|
||||
}
|
||||
|
||||
fun setChapterTrackMode(enabled: Boolean) {
|
||||
_state.value = _state.value.copy(chapterTrackMode = enabled)
|
||||
}
|
||||
|
||||
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
|
||||
if (isClosed) {
|
||||
isClosed = false
|
||||
scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
}
|
||||
absClient = client
|
||||
currentItemId = item.id
|
||||
|
||||
val prevSpeed = _state.value.speed
|
||||
val chTrackMode = _state.value.chapterTrackMode
|
||||
_state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode)
|
||||
|
||||
// Cancel in-progress jobs from a previous session
|
||||
positionObserverJob?.cancel()
|
||||
stopSync()
|
||||
cancelSleepInternal()
|
||||
|
||||
try {
|
||||
val session = client.startSession(item.id)
|
||||
val tracks = session.audioTracks
|
||||
val chapters = session.chapters.ifEmpty { client.getItemChapters(item.id) }
|
||||
val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration }
|
||||
|
||||
val saved = client.getProgress(item.id)
|
||||
val startTime = when {
|
||||
(saved?.currentTime ?: 0.0) > 10.0 -> saved!!.currentTime
|
||||
session.currentTime > 10.0 -> session.currentTime
|
||||
else -> 0.0
|
||||
}
|
||||
|
||||
audioTracks = tracks
|
||||
currentItemId = item.id
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
sessionId = session.id,
|
||||
chapters = chapters,
|
||||
duration = duration,
|
||||
currentTime = startTime,
|
||||
chapterIdx = findChapterIdx(chapters, startTime),
|
||||
)
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
_state.value = _state.value.copy(error = "Keine Audiotracks gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
val meta = item.media.metadata
|
||||
val artworkUri = client.coverUrl(item.id)?.let { Uri.parse(it) }
|
||||
|
||||
// Each ABS track gets mediaId "abs:<contentUrl>" so PlayerController can detect
|
||||
// ABS items and skip Navidrome scrobbling / last-song tracking.
|
||||
val mediaItems = tracks.map { t ->
|
||||
MediaItem.Builder()
|
||||
.setMediaId("abs:${t.contentUrl}")
|
||||
.setUri(client.audioTrackUrl(t.contentUrl))
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(meta?.title ?: "Hörbuch")
|
||||
.setArtist(meta?.authorName ?: "")
|
||||
.setArtworkUri(artworkUri)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
val (trackIdx, localSecs) = findTrack(tracks, startTime)
|
||||
// Route audio through MusicService — same notification, same mini player
|
||||
appContext.app.playerController.playAbsItems(
|
||||
items = mediaItems,
|
||||
startIndex = trackIdx,
|
||||
startPositionMs = (localSecs * 1000).toLong(),
|
||||
speed = prevSpeed,
|
||||
)
|
||||
|
||||
startPositionObserver()
|
||||
startSync()
|
||||
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message ?: "Fehler beim Laden")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Position observer — keeps AbsPlayState in sync with MusicService player ──
|
||||
|
||||
private fun startPositionObserver() {
|
||||
positionObserverJob?.cancel()
|
||||
positionObserverJob = scope.launch {
|
||||
appContext.app.playerController.state.collect { playerState ->
|
||||
val s = _state.value
|
||||
if (s.item == null || audioTracks.isEmpty()) return@collect
|
||||
|
||||
val trackIdx = playerState.currentIndex.coerceAtLeast(0)
|
||||
val track = audioTracks.getOrNull(trackIdx) ?: return@collect
|
||||
val localSecs = playerState.positionMs / 1000.0
|
||||
val globalTime = track.startOffset + localSecs
|
||||
val chapterIdx = findChapterIdx(s.chapters, globalTime)
|
||||
|
||||
_state.value = s.copy(
|
||||
isPlaying = playerState.isPlaying,
|
||||
currentTime = globalTime,
|
||||
chapterIdx = chapterIdx,
|
||||
)
|
||||
|
||||
if (playerState.isPlaying) startSync() else { stopSync(); doSync() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback controls — delegate to PlayerController ─────────────────────
|
||||
|
||||
fun togglePlay() = appContext.app.playerController.togglePlay()
|
||||
fun pause() = appContext.app.playerController.pause()
|
||||
fun resume() = appContext.app.playerController.play()
|
||||
|
||||
fun seekToGlobal(globalSecs: Double) {
|
||||
val clamped = globalSecs.coerceIn(0.0, _state.value.duration)
|
||||
val (trackIdx, localSecs) = findTrack(audioTracks, clamped)
|
||||
appContext.app.playerController.seekToAbs(trackIdx, (localSecs * 1000).toLong())
|
||||
_state.value = _state.value.copy(
|
||||
currentTime = clamped,
|
||||
chapterIdx = findChapterIdx(_state.value.chapters, clamped),
|
||||
)
|
||||
}
|
||||
|
||||
fun nextChapter() {
|
||||
val s = _state.value
|
||||
val next = s.chapters.getOrNull(s.chapterIdx + 1) ?: return
|
||||
seekToGlobal(next.start)
|
||||
}
|
||||
|
||||
fun prevChapter() {
|
||||
val s = _state.value
|
||||
val cur = s.chapters.getOrNull(s.chapterIdx)
|
||||
if (cur != null && s.currentTime - cur.start > 5) { seekToGlobal(cur.start); return }
|
||||
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
|
||||
seekToGlobal(prev.start)
|
||||
}
|
||||
|
||||
fun seekToChapter(idx: Int) {
|
||||
val ch = _state.value.chapters.getOrNull(idx) ?: return
|
||||
seekToGlobal(ch.start)
|
||||
}
|
||||
|
||||
fun skipSecs(secs: Double) = seekToGlobal(_state.value.currentTime + secs)
|
||||
|
||||
fun setSpeed(speed: Float) {
|
||||
_state.value = _state.value.copy(speed = speed)
|
||||
appContext.app.playerController.setSpeed(speed)
|
||||
}
|
||||
|
||||
// ── Sleep timer ───────────────────────────────────────────────────────────
|
||||
|
||||
fun setSleepTimer(minutes: Int) {
|
||||
cancelSleepInternal()
|
||||
if (minutes <= 0) { _state.value = _state.value.copy(sleepRemainingSecs = null); return }
|
||||
val totalSecs = minutes * 60
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = totalSecs)
|
||||
sleepJob = scope.launch {
|
||||
delay(totalSecs * 1000L)
|
||||
appContext.app.playerController.pause()
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = null)
|
||||
}
|
||||
sleepTickJob = scope.launch {
|
||||
var rem = totalSecs
|
||||
while (rem > 0 && isActive) {
|
||||
delay(1000)
|
||||
rem--
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = rem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSleep() {
|
||||
cancelSleepInternal()
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = null)
|
||||
}
|
||||
|
||||
private fun cancelSleepInternal() {
|
||||
sleepJob?.cancel(); sleepJob = null
|
||||
sleepTickJob?.cancel(); sleepTickJob = null
|
||||
}
|
||||
|
||||
// ── Progress sync to ABS server ───────────────────────────────────────────
|
||||
|
||||
private fun startSync() {
|
||||
if (syncJob?.isActive == true) return
|
||||
syncJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(30_000)
|
||||
doSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSync() { syncJob?.cancel(); syncJob = null }
|
||||
|
||||
private fun doSync() {
|
||||
val itemId = currentItemId ?: return
|
||||
val client = absClient ?: return
|
||||
val s = _state.value
|
||||
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fun close() {
|
||||
if (isClosed) return
|
||||
isClosed = true
|
||||
|
||||
// Save progress and close ABS session on a detached scope so it survives
|
||||
// cancellation of the main scope below.
|
||||
val sessionId = _state.value.sessionId
|
||||
val client = absClient
|
||||
val itemId = currentItemId
|
||||
val ct = _state.value.currentTime
|
||||
val dur = _state.value.duration
|
||||
if (sessionId != null && client != null && itemId != null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching { client.saveProgress(itemId, ct, dur) }
|
||||
runCatching { client.closeSession(sessionId) }
|
||||
}
|
||||
}
|
||||
|
||||
positionObserverJob?.cancel()
|
||||
stopSync()
|
||||
cancelSleepInternal()
|
||||
appContext.app.playerController.stop()
|
||||
_state.value = AbsPlayState()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
|
||||
if (chapters.isEmpty()) return 0
|
||||
var idx = 0
|
||||
for (i in chapters.indices) { if (chapters[i].start <= time) idx = i }
|
||||
return idx
|
||||
}
|
||||
|
||||
private fun findTrack(tracks: List<AbsAudioTrack>, globalTime: Double): Pair<Int, Double> {
|
||||
if (tracks.isEmpty()) return Pair(0, globalTime)
|
||||
var idx = 0
|
||||
for (i in tracks.indices) { if (tracks[i].startOffset <= globalTime) idx = i }
|
||||
val local = (globalTime - tracks[idx].startOffset).coerceAtLeast(0.0)
|
||||
return Pair(idx, local)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,11 @@ import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.R
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.ui.MainActivity
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -33,12 +36,25 @@ class MusicService : MediaLibraryService() {
|
||||
// Pseudo-items for play-all / shuffle at top of each list
|
||||
const val PLAY_ALL_PREFIX = "playall:"
|
||||
const val SHUFFLE_PREFIX = "shuffle:"
|
||||
// Encodes a song's browse context so a tap plays the whole list from that song:
|
||||
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
|
||||
const val SONG_CTX_PREFIX = "songctx:"
|
||||
// ABS session commands (handled via onCustomCommand)
|
||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
|
||||
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
|
||||
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
|
||||
}
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var session: MediaLibrarySession
|
||||
private lateinit var repo: MusicRepository
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> Log.e("MusicService", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private var lastAbsChapterIdx = -1
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -69,6 +85,8 @@ class MusicService : MediaLibraryService() {
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
|
||||
val wrappedPlayer = AbsAwareForwardingPlayer(player)
|
||||
|
||||
val activityIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
@@ -77,16 +95,145 @@ class MusicService : MediaLibraryService() {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
|
||||
session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(activityIntent)
|
||||
.build()
|
||||
|
||||
// Keep credentials cached so coverArtUrl/streamUrl work when Auto calls us
|
||||
// without the UI running (AppNavigation never subscribes on this repo instance).
|
||||
scope.launch {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
scope.launch {
|
||||
(applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
|
||||
session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
|
||||
|
||||
val newIdx = if (s.item != null) s.chapterIdx else -1
|
||||
if (newIdx != lastAbsChapterIdx) {
|
||||
lastAbsChapterIdx = newIdx
|
||||
if (s.item != null) {
|
||||
val chapter = s.chapters.getOrNull(s.chapterIdx)
|
||||
val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
|
||||
val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
|
||||
val cur = player.getMediaItemAt(idx)
|
||||
player.replaceMediaItem(idx, cur.buildUpon()
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(chapter?.title?.takeIf { it.isNotBlank() } ?: bookTitle)
|
||||
.setArtist(if (!chapter?.title.isNullOrBlank()) bookTitle else cur.mediaMetadata.artist)
|
||||
.setArtworkUri(cur.mediaMetadata.artworkUri)
|
||||
.build()
|
||||
).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setMediaNotificationProvider(AbsAwareDnmp())
|
||||
}
|
||||
|
||||
private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
|
||||
val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
|
||||
val ppLabel = if (isPlaying) "Pause" else "Abspielen"
|
||||
return ImmutableList.of(
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_prev)
|
||||
.setDisplayName("Kapitel zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_rewind)
|
||||
.setDisplayName("30s zurück")
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY))
|
||||
.setIconResId(ppIcon)
|
||||
.setDisplayName(ppLabel)
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_forward)
|
||||
.setDisplayName("30s vor")
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_next)
|
||||
.setDisplayName("Kapitel vor")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// 4-button layout for the Samsung media widget — play/pause is the standard player control.
|
||||
// prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
|
||||
// Samsung One UI slot mapping (empirically determined):
|
||||
// prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
|
||||
// Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
|
||||
// Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
|
||||
private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_rewind)
|
||||
.setDisplayName("30s zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_forward)
|
||||
.setDisplayName("30s vor")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_prev)
|
||||
.setDisplayName("Kapitel zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_next)
|
||||
.setDisplayName("Kapitel vor")
|
||||
.build(),
|
||||
)
|
||||
|
||||
// Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
|
||||
// replaces its standard prev/next slots with our first/last custom layout buttons.
|
||||
private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
val base = super.getAvailableCommands()
|
||||
val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
|
||||
(applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
|
||||
return if (isAbs) {
|
||||
base.buildUpon()
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.build()
|
||||
} else base
|
||||
}
|
||||
}
|
||||
|
||||
// Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
|
||||
// createNotification() stores the session and always passes ImmutableList.of() to super
|
||||
// so DNMP never appends setCustomLayout() buttons after getMediaButtons().
|
||||
// getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
|
||||
// getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
|
||||
// No createNotification() override needed. setCustomLayout() is always called with empty list
|
||||
// (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
|
||||
private inner class AbsAwareDnmp : DefaultMediaNotificationProvider(this@MusicService) {
|
||||
override fun getMediaButtons(
|
||||
mediaSession: MediaSession,
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
showPauseButton: Boolean,
|
||||
): ImmutableList<CommandButton> {
|
||||
val mediaId = mediaSession.player.currentMediaItem?.mediaId
|
||||
val absItem = (applicationContext as NovaSonicApp).absPlayerController.state.value.item
|
||||
android.util.Log.e("ABS_NOTIF", "getMediaButtons: mediaId=$mediaId absItem=${absItem?.id} customLayout.size=${customLayout.size}")
|
||||
val isAbs = mediaId?.startsWith("abs:") == true || absItem != null
|
||||
if (!isAbs) return super.getMediaButtons(mediaSession, playerCommands, ImmutableList.of(), showPauseButton)
|
||||
return buildAbsButtons(mediaSession.player.isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
@@ -104,6 +251,41 @@ class MusicService : MediaLibraryService() {
|
||||
|
||||
private inner class LibraryCallback : MediaLibrarySession.Callback {
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): MediaSession.ConnectionResult {
|
||||
val absCommands = listOf(
|
||||
SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY),
|
||||
)
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(
|
||||
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
.addSessionCommands(absCommands).build()
|
||||
).build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle,
|
||||
): ListenableFuture<SessionResult> {
|
||||
val abs = (applicationContext as NovaSonicApp).absPlayerController
|
||||
when (customCommand.customAction) {
|
||||
ACTION_ABS_PREV_CH -> abs.prevChapter()
|
||||
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
|
||||
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
|
||||
ACTION_ABS_NEXT_CH -> abs.nextChapter()
|
||||
ACTION_ABS_TOGGLE -> abs.togglePlay()
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
@@ -155,6 +337,25 @@ class MusicService : MediaLibraryService() {
|
||||
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.toMediaItem(repo)
|
||||
}
|
||||
mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
val artUri = pl.coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
|
||||
MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(pl.name)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
mediaId.startsWith(SONG_CTX_PREFIX) -> {
|
||||
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
|
||||
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
@@ -166,42 +367,78 @@ class MusicService : MediaLibraryService() {
|
||||
return settable
|
||||
}
|
||||
|
||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
||||
// Android Auto "add to queue" path — resolve browsable items to their songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
|
||||
catch (e: Exception) { settable.set(mediaItems) }
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
|
||||
// tapping a song plays its whole list from that song (not just the one song).
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||
scope.launch {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
||||
}
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
else -> listOf(item)
|
||||
}
|
||||
}
|
||||
settable.set(resolved.toMutableList())
|
||||
val (list, idx) = expandForPlayback(mediaItems)
|
||||
// A single browse tap expands into a full list → start at the tapped index.
|
||||
// Otherwise honour the caller's startIndex.
|
||||
val expanded = list.size != mediaItems.size
|
||||
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||
} catch (e: Exception) {
|
||||
settable.set(mediaItems)
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
|
||||
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
|
||||
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
|
||||
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
|
||||
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
|
||||
val ctx = rest.substringBefore('|')
|
||||
val songId = rest.substringAfter('|')
|
||||
val full = resolveSongs(ctx, shuffle = false)
|
||||
if (full.isNotEmpty()) {
|
||||
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
|
||||
return full to idx
|
||||
}
|
||||
return listOf(reattachUri(items[0], songId)) to 0
|
||||
}
|
||||
val resolved = items.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
|
||||
else -> listOf(reattachUri(item))
|
||||
}
|
||||
}
|
||||
return resolved to 0
|
||||
}
|
||||
|
||||
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
|
||||
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
|
||||
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
|
||||
return item.buildUpon().setMediaId(songId).setUri(uri).build()
|
||||
}
|
||||
|
||||
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||
@@ -238,16 +475,16 @@ class MusicService : MediaLibraryService() {
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||
FAVORITES_ID -> {
|
||||
val songs = repo.getStarred().song
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
|
||||
}
|
||||
else -> when {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
@@ -255,6 +492,13 @@ class MusicService : MediaLibraryService() {
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
|
||||
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
|
||||
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
|
||||
song.toMediaItem(repo).buildUpon()
|
||||
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
|
||||
.build()
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
@@ -343,7 +587,7 @@ fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): Med
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(true)
|
||||
.setIsPlayable(false) // browse-only: Auto opens the song list instead of playing immediately
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.novasonic.app.player
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -7,6 +7,8 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.*
|
||||
@@ -35,7 +37,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private var controller: MediaController? = null
|
||||
private var queue: List<Song> = emptyList()
|
||||
private var originalQueue: List<Song> = emptyList()
|
||||
@@ -58,6 +63,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
// If ABS is active, close it — user explicitly switched to music
|
||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||
if (abs.isActive) abs.close()
|
||||
}
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
@@ -70,6 +79,35 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Called by AbsPlayerController to route ABS audio through this service. */
|
||||
fun playAbsItems(
|
||||
items: List<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
speed: Float = 1f,
|
||||
) {
|
||||
val ctrl = controller ?: return
|
||||
queue = emptyList()
|
||||
originalQueue = emptyList()
|
||||
isManuallyShuffled = false
|
||||
ctrl.setMediaItems(items, startIndex, startPositionMs)
|
||||
ctrl.prepare()
|
||||
ctrl.setPlaybackSpeed(speed)
|
||||
ctrl.play()
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Stop playback and clear the queue (used when ABS session ends). */
|
||||
fun stop() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.setPlaybackSpeed(1f)
|
||||
ctrl.pause()
|
||||
ctrl.clearMediaItems()
|
||||
queue = emptyList()
|
||||
originalQueue = emptyList()
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun play() = controller?.play()
|
||||
fun pause() = controller?.pause()
|
||||
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
|
||||
@@ -78,6 +116,11 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
fun skipPrev() = controller?.seekToPreviousMediaItem()
|
||||
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
|
||||
|
||||
/** Seek to a specific track index and position within that track (for ABS chapter navigation). */
|
||||
fun seekToAbs(windowIndex: Int, positionMs: Long) = controller?.seekTo(windowIndex, positionMs)
|
||||
|
||||
fun setSpeed(speed: Float) = controller?.setPlaybackSpeed(speed)
|
||||
|
||||
fun setVolume(v: Float) {
|
||||
val clamped = v.coerceIn(0f, 1f)
|
||||
controller?.volume = clamped
|
||||
@@ -113,7 +156,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
// Turn ON: shuffle everything after the current song
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
@@ -121,7 +163,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
} else {
|
||||
// Turn OFF: restore original order from the current song's position
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
@@ -165,7 +206,6 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
|
||||
}
|
||||
|
||||
// Small library fallback: allow repeats rather than stopping
|
||||
if (candidates.isEmpty()) {
|
||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -187,11 +227,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
val ctrl = controller ?: break
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition) }
|
||||
// Refresh durationMs too — after a SEAMLESS track transition (skipping through
|
||||
// already-buffered tracks) the player stays READY, so onPlaybackStateChanged never
|
||||
// fires and syncState() isn't re-called; ctrl.duration was TIME_UNSET(→0) at the
|
||||
// transition, leaving durationMs stuck at 0. Seek then computes fraction*0 = 0 and
|
||||
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Player duration, falling back to the current song's known duration while ctrl.duration is
|
||||
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
|
||||
private fun resolveDurationMs(ctrl: MediaController): Long {
|
||||
val d = ctrl.duration
|
||||
if (d > 0) return d
|
||||
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
|
||||
return songDurSecs * 1000L
|
||||
}
|
||||
|
||||
private fun stopTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = null
|
||||
@@ -205,6 +259,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||
delay(delayMs)
|
||||
runCatching { repo.scrobble(song.id) }
|
||||
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +285,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
currentSong = queue.getOrNull(idx),
|
||||
currentIndex = idx,
|
||||
positionMs = ctrl.currentPosition,
|
||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
||||
durationMs = resolveDurationMs(ctrl),
|
||||
queue = queue,
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
@@ -240,6 +295,24 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
)
|
||||
}
|
||||
|
||||
private var _lyricsPrefetchJob: Job? = null
|
||||
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
|
||||
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
|
||||
private fun prefetchLyrics(song: Song) {
|
||||
_lyricsPrefetchJob?.cancel()
|
||||
_lyricsPrefetchJob = scope.launch {
|
||||
delay(1500) // skip quickly without firing a request per track
|
||||
runCatching {
|
||||
LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
@@ -252,9 +325,18 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
syncState()
|
||||
checkRadioRefill()
|
||||
val ctrl = controller ?: return
|
||||
// Skip scrobble and last-song tracking for ABS tracks (mediaId starts with "abs:")
|
||||
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
||||
if (mediaId.startsWith("abs:")) return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
val dur = ctrl.duration.coerceAtLeast(0)
|
||||
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
|
||||
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
|
||||
// far too early. Fall back to the player only if the song has no duration.
|
||||
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
|
||||
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||
prefetchLyrics(song)
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
||||
@@ -263,7 +345,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
val ctrl = controller ?: return
|
||||
val ctrlCount = ctrl.mediaItemCount
|
||||
// Rebuild queue from controller when items were set externally (e.g. Android Auto)
|
||||
// Rebuild queue from controller when items were set externally (e.g. Android Auto or ABS)
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
|
||||
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
|
||||
originalQueue = queue
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a
|
||||
* screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the
|
||||
* user leaves the album/artist/Explore screen it started on. Previously the preview lived in
|
||||
* ExploreViewModel and became uncontrollable once you navigated away.
|
||||
*/
|
||||
data class PreviewState(
|
||||
val trackId: Long? = null,
|
||||
val title: String = "",
|
||||
val artist: String = "",
|
||||
val artworkUrl: String? = null,
|
||||
val isPlaying: Boolean = false, // true = actively playing, false = paused
|
||||
)
|
||||
|
||||
class PreviewController {
|
||||
|
||||
private val _state = MutableStateFlow(PreviewState())
|
||||
val state: StateFlow<PreviewState> = _state
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var mp: MediaPlayer? = null
|
||||
|
||||
/** Tap on a track: same track → toggle play/pause; different track → start it. */
|
||||
fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||
val cur = _state.value
|
||||
when {
|
||||
cur.trackId == trackId && cur.isPlaying -> pause()
|
||||
cur.trackId == trackId && mp != null -> resume()
|
||||
else -> start(trackId, url, title, artist, artworkUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||
stop()
|
||||
_state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val player = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { stop() }
|
||||
}
|
||||
mp = player
|
||||
} catch (e: Exception) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mp?.runCatching { pause() }
|
||||
_state.update { it.copy(isPlaying = false) }
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
val player = mp
|
||||
if (player == null) return
|
||||
player.runCatching { start() }
|
||||
_state.update { it.copy(isPlaying = true) }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mp?.runCatching { stop(); release() }
|
||||
mp = null
|
||||
_state.value = PreviewState()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.novasonic.app.ui
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
@@ -16,22 +16,27 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.AppNotification
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.component.MiniPlayer
|
||||
import com.novasonic.app.ui.component.PreviewBar
|
||||
import com.novasonic.app.ui.component.NotificationSheet
|
||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||
import com.novasonic.app.ui.component.SongContextSheet
|
||||
@@ -45,11 +50,16 @@ private enum class TopLevel(val route: String, val icon: ImageVector, val label:
|
||||
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
||||
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
||||
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
||||
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
|
||||
ABS("abs", Icons.Default.Book, "Bücher"),
|
||||
SETTINGS("settings", Icons.Default.Settings, "Einst."),
|
||||
}
|
||||
|
||||
private val NavBg = Color(0xF7080814)
|
||||
|
||||
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
|
||||
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
|
||||
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val context = LocalContext.current
|
||||
@@ -62,20 +72,80 @@ fun AppNavigation() {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
|
||||
// Apply saved theme whenever settings change
|
||||
LaunchedEffect(Unit) {
|
||||
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
|
||||
}
|
||||
|
||||
val playerState by player.state.collectAsStateWithLifecycle()
|
||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
||||
|
||||
// ── In-app updater — offer the newer APK the backend advertises ─────────────
|
||||
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
|
||||
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
|
||||
val updateScope = rememberCoroutineScope()
|
||||
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
|
||||
updateInfo?.let { info ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
|
||||
title = { Text("Update verfügbar") },
|
||||
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
|
||||
confirmButton = {
|
||||
TextButton(enabled = !updating, onClick = {
|
||||
updateScope.launch {
|
||||
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
|
||||
)
|
||||
}
|
||||
|
||||
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
|
||||
// cancels any active preview so the two don't overlap.
|
||||
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() }
|
||||
|
||||
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
|
||||
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
||||
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
|
||||
else null
|
||||
}
|
||||
|
||||
val absPlayerController = context.app.absPlayerController
|
||||
val absState by absPlayerController.state.collectAsStateWithLifecycle()
|
||||
DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
|
||||
|
||||
LaunchedEffect(appSettings.absChapterTrackMode) {
|
||||
absPlayerController.setChapterTrackMode(appSettings.absChapterTrackMode)
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
var showNowPlaying by remember { mutableStateOf(false) }
|
||||
|
||||
// Re-register a fresh callback each time NowPlaying opens so it is always
|
||||
// the LAST entry in the dispatcher queue (LIFO = highest priority), beating
|
||||
// the BackHandler that NavHost registers internally for its own back stack.
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
||||
var showAbsPlayer by remember { mutableStateOf(false) }
|
||||
// Close action for the Erkunden (Apple) in-screen overlay (iTunes artist / album). Handled at the
|
||||
// app root because a BackHandler inside the NavHost "erkunden" destination loses back priority to
|
||||
// the NavHost, which pops Erkunden → Home instead of dismissing the overlay.
|
||||
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
DisposableEffect(showNowPlaying, backDispatcher) {
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
// Returning from the background re-registers the NavController's OWN back callback on top of the
|
||||
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
|
||||
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
|
||||
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
|
||||
var resumeTick by remember { mutableIntStateOf(0) }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
|
||||
|
||||
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||
@@ -83,55 +153,121 @@ fun AppNavigation() {
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||
|
||||
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
||||
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
|
||||
// Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API.
|
||||
com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred)
|
||||
}
|
||||
|
||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||
|
||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
|
||||
// Root-level back for the Erkunden (Apple) overlay. Keyed on currentBackEntry as well, so the
|
||||
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
|
||||
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
|
||||
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
|
||||
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
|
||||
val action = exploreOverlayBack
|
||||
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { action() }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
|
||||
val bgGlow = Brush.radialGradient(
|
||||
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
||||
center = Offset.Unspecified, radius = 900f,
|
||||
)
|
||||
|
||||
// Nav items — ABS tab only shown when configured; Settings moved to HomeScreen header
|
||||
val navItems = remember(absConfigured) {
|
||||
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
|
||||
.filter { it != TopLevel.SETTINGS }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
containerColor = NsBackground,
|
||||
modifier = Modifier.background(NsBackground).background(bgGlow),
|
||||
bottomBar = {
|
||||
if (isTopLevel) {
|
||||
Column {
|
||||
// MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
|
||||
// is always reachable; the tab bar stays top-level-only. On detail screens there is no
|
||||
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
|
||||
// the MiniPlayer sits behind the phone's gesture/nav bar.
|
||||
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
|
||||
// 30s preview control bar — above the MiniPlayer, on every screen, so a preview
|
||||
// started on an album/artist/Explore screen can be paused/stopped from anywhere.
|
||||
AnimatedVisibility(
|
||||
visible = playerState.currentSong != null,
|
||||
visible = previewState.trackId != null,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
PreviewBar(
|
||||
state = previewState,
|
||||
onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() },
|
||||
onClose = { app.previewController.stop() },
|
||||
)
|
||||
}
|
||||
// ABS audio routes through MusicService — one notification, one mini player.
|
||||
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
||||
// instead of the music NowPlaying screen.
|
||||
val hasAbsItem = absState.item != null
|
||||
val showMiniPlayer = playerState.isPlaying ||
|
||||
playerState.currentSong != null ||
|
||||
(lastPlayedSong != null && !showNowPlaying)
|
||||
AnimatedVisibility(
|
||||
visible = showMiniPlayer,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||
onTap = { showNowPlaying = true },
|
||||
onTogglePlay = { player.togglePlay() },
|
||||
onSkipPrev = { player.skipPrev() },
|
||||
onSkipNext = { player.skipNext() },
|
||||
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
|
||||
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
|
||||
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
|
||||
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
|
||||
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
|
||||
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
|
||||
onPlayLast = {
|
||||
lastPlayedSong?.let { ls ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val song = repo.getSong(ls.id)
|
||||
player.playSongs(listOf(song), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (isTopLevel) {
|
||||
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||
TopLevel.entries.forEach { dest ->
|
||||
navItems.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
@@ -145,10 +281,7 @@ fun AppNavigation() {
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||
label = {
|
||||
Text(dest.label, style = TextStyle(
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
|
||||
},
|
||||
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 9.sp), maxLines = 1) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = NsAccent,
|
||||
selectedTextColor = NsAccent,
|
||||
@@ -167,27 +300,45 @@ fun AppNavigation() {
|
||||
navController = navController,
|
||||
startDestination = TopLevel.HOME.route,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
// Direction of a tab switch: +1 if the target tab sits to the right of the current
|
||||
// one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
|
||||
},
|
||||
) {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onBellClick = { showNotifications = true },
|
||||
onSettingsClick = { navController.navigate(TopLevel.SETTINGS.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
} },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
scope.launch { runCatching {
|
||||
@@ -212,6 +363,8 @@ fun AppNavigation() {
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onOpenOffline = { group -> navController.navigate("offline/${encodeRoute(group.groupId)}") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ERKUNDEN.route) {
|
||||
@@ -221,6 +374,8 @@ fun AppNavigation() {
|
||||
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onOverlayBackChange = { exploreOverlayBack = it },
|
||||
onOpenImporter = { navController.navigate("importer") },
|
||||
)
|
||||
}
|
||||
@@ -236,7 +391,47 @@ fun AppNavigation() {
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
)
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
|
||||
composable(TopLevel.ABS.route) {
|
||||
val client = absClient
|
||||
if (client != null) {
|
||||
if (selectedAbsItem != null) {
|
||||
AbsBookDetailScreen(
|
||||
absClient = client,
|
||||
libraryItem = selectedAbsItem!!,
|
||||
absPlayerController = absPlayerController,
|
||||
onPlay = { showAbsPlayer = true },
|
||||
onBack = { selectedAbsItem = null },
|
||||
)
|
||||
} else {
|
||||
AbsBooksScreen(
|
||||
absClient = client,
|
||||
onItemClick = { selectedAbsItem = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) {
|
||||
SettingsScreen(
|
||||
onOpenStats = { navController.navigate("stats") },
|
||||
onOpenCompare = { navController.navigate("compare") },
|
||||
)
|
||||
}
|
||||
composable("stats") {
|
||||
StatsScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
composable("compare") {
|
||||
CompareScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"album/{albumId}",
|
||||
@@ -277,6 +472,18 @@ fun AppNavigation() {
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onDeleted = { navController.popBackStack() },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"offline/{groupId}",
|
||||
arguments = listOf(navArgument("groupId") { type = NavType.StringType }),
|
||||
) {
|
||||
OfflineDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -310,7 +517,7 @@ fun AppNavigation() {
|
||||
}
|
||||
}
|
||||
|
||||
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
|
||||
// NowPlaying overlay
|
||||
AnimatedVisibility(
|
||||
visible = showNowPlaying,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
@@ -322,12 +529,8 @@ fun AppNavigation() {
|
||||
queueCoverUrl = { id -> coverUrl(id) },
|
||||
controller = player,
|
||||
onDismiss = { showNowPlaying = false },
|
||||
onStarToggle = {
|
||||
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
|
||||
},
|
||||
onShowOptions = {
|
||||
playerState.currentSong?.let { s -> contextMenuSong = s }
|
||||
},
|
||||
onStarToggle = { playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) } },
|
||||
onShowOptions = { playerState.currentSong?.let { s -> contextMenuSong = s } },
|
||||
onArtistClick = { artistId ->
|
||||
showNowPlaying = false
|
||||
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
||||
@@ -336,39 +539,57 @@ fun AppNavigation() {
|
||||
)
|
||||
}
|
||||
|
||||
// Song context menu bottom sheet
|
||||
// ABS player overlay
|
||||
AnimatedVisibility(
|
||||
visible = showAbsPlayer,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
val client = absClient
|
||||
if (client != null) {
|
||||
AbsNowPlayingScreen(
|
||||
absPlayerController = absPlayerController,
|
||||
absClient = client,
|
||||
onDismiss = { showAbsPlayer = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
contextMenuSong?.let { song ->
|
||||
SongContextSheet(
|
||||
song = song,
|
||||
isStarred = song.starred != null,
|
||||
isStarred = com.novasonic.app.data.StarState.isStarred(song),
|
||||
onDismiss = { contextMenuSong = null },
|
||||
onPlay = { player.playSongs(listOf(song), 0) },
|
||||
onInsertNext = { player.insertNext(song) },
|
||||
onAddToQueue = { player.addToQueueEnd(song) },
|
||||
onStarToggle = { starToggle(song.id, song.starred != null) },
|
||||
onAddToPlaylist = {
|
||||
playlistPickerSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onGoToAlbum = song.albumId?.let { albumId ->
|
||||
{ navController.navigate("album/$albumId") }
|
||||
},
|
||||
onShowInfo = {
|
||||
infoSheetSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) },
|
||||
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
||||
onDownload = {
|
||||
downloadSong(context, song, repo.streamUrl(song.id) ?: "")
|
||||
scope.launch {
|
||||
runCatching {
|
||||
app.offlineRepository.enqueueGroup(
|
||||
type = com.novasonic.app.offline.GroupType.SONG,
|
||||
sourceId = song.id,
|
||||
name = song.title,
|
||||
coverArt = song.coverArt,
|
||||
songs = listOf(song),
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AppNavigation", "enqueueGroup failed", it) }
|
||||
}
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
contextMenuSong = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Playlist picker sheet
|
||||
playlistPickerSong?.let { song ->
|
||||
PlaylistPickerSheet(
|
||||
song = song,
|
||||
loadPlaylists = { repo.getPlaylists() },
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onAdd = { pl ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
@@ -389,34 +610,48 @@ fun AppNavigation() {
|
||||
)
|
||||
}
|
||||
|
||||
// Song info sheet
|
||||
infoSheetSong?.let { song ->
|
||||
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
||||
}
|
||||
|
||||
// Notification sheet
|
||||
if (showNotifications) {
|
||||
NotificationSheet(onDismiss = { showNotifications = false })
|
||||
NotificationSheet(
|
||||
onDismiss = { showNotifications = false },
|
||||
onNavigate = { n ->
|
||||
showNotifications = false
|
||||
scope.launch {
|
||||
runCatching {
|
||||
when {
|
||||
n.navType == "playlist" && n.navId.isNotBlank() -> {
|
||||
navController.navigate("playlist/${n.navId}")
|
||||
}
|
||||
n.navAlbum.isNotBlank() -> {
|
||||
val res = repo.search(n.navAlbum)
|
||||
val albums = res.album
|
||||
val norm = { s: String -> s.lowercase().trim() }
|
||||
val na = norm(n.navArtist)
|
||||
// REQUIRE the artist match when we know it — several artists share an
|
||||
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
|
||||
// opened a completely different artist's album (wrong song).
|
||||
val match = albums.firstOrNull {
|
||||
val aa = norm(it.artist ?: "")
|
||||
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
|
||||
(aa == na || aa.contains(na) || na.contains(aa))
|
||||
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
|
||||
if (match != null) {
|
||||
navController.navigate("album/${match.id}")
|
||||
} else {
|
||||
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSong(context: Context, song: Song, streamUrl: String) {
|
||||
if (streamUrl.isBlank()) {
|
||||
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val safe = (song.title + "." + (song.suffix ?: "mp3"))
|
||||
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
||||
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
||||
.setTitle(song.title)
|
||||
.setDescription(song.artist ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe)
|
||||
.setMimeType(song.contentType ?: "audio/mpeg")
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||
@@ -35,12 +35,15 @@ private fun NovaSonicRoot() {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { PrefsStore(context) }
|
||||
val creds by prefs.credentials.collectAsStateWithLifecycle(
|
||||
initialValue = PrefsStore.Credentials("", "", ""),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
var forceLogin by remember { mutableStateOf(false) }
|
||||
|
||||
if (!creds.isValid || forceLogin) {
|
||||
// null = still loading from DataStore; don't show anything yet to avoid login screen flash
|
||||
if (creds == null) return
|
||||
|
||||
if (!creds!!.isValid || forceLogin) {
|
||||
LoginScreen(onLoggedIn = { forceLogin = false })
|
||||
} else {
|
||||
AppNavigation()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
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 coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.player.AbsPlayState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun AbsMiniPlayer(
|
||||
state: AbsPlayState,
|
||||
absClient: AbsClient,
|
||||
onTap: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val item = state.item ?: return
|
||||
val meta = item.media.metadata
|
||||
val chapterName = state.chapters.getOrNull(state.chapterIdx)?.title
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(item.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta?.title ?: "Hörbuch",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = chapterName ?: meta?.authorName ?: "",
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.duration > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.currentTime / state.duration).toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent,
|
||||
trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,15 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -28,6 +33,7 @@ fun AlbumCard(
|
||||
album: Album,
|
||||
coverUrl: String?,
|
||||
width: Dp = 145.dp,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
@@ -40,6 +46,7 @@ fun AlbumCard(
|
||||
.border(1.dp, cardBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
@@ -49,6 +56,25 @@ fun AlbumCard(
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = album.name,
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
@@ -31,6 +32,7 @@ fun ConcertsSheet(
|
||||
artistName: String,
|
||||
events: List<ConcertEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
eventsError: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -47,17 +49,25 @@ fun ConcertsSheet(
|
||||
)
|
||||
|
||||
if (events.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||
) {
|
||||
Text(
|
||||
"Keine Konzerte angekündigt",
|
||||
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
if (eventsError != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
eventsError,
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -78,7 +88,7 @@ fun ConcertsSheet(
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
|
||||
val w = formatEventWhen(event.datetime)
|
||||
val venue = event.venue?.name.orEmpty()
|
||||
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
||||
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
||||
@@ -92,26 +102,33 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
// Date block
|
||||
// Date block — fixed width, calendar style (day big, month/year + time small) so every
|
||||
// row is the same height and the divider/venue line up.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(52.dp),
|
||||
modifier = Modifier.width(56.dp),
|
||||
) {
|
||||
Text(
|
||||
dateStr,
|
||||
w.day,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsAccent,
|
||||
fontSize = 18.sp, color = NsAccent, maxLines = 1,
|
||||
)
|
||||
if (timeStr.isNotBlank()) {
|
||||
if (w.monthYear.isNotBlank()) {
|
||||
Text(
|
||||
timeStr,
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
w.monthYear,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (w.time.isNotBlank()) {
|
||||
Text(
|
||||
w.time,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
|
||||
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
// Venue
|
||||
@@ -156,14 +173,21 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatEventDateTime(datetime: String): Pair<String, String> {
|
||||
private data class EventWhen(val day: String, val monthYear: String, val time: String)
|
||||
|
||||
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
|
||||
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
|
||||
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
private fun formatEventWhen(datetime: String): EventWhen {
|
||||
return try {
|
||||
val zdt = ZonedDateTime.parse(datetime)
|
||||
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
|
||||
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
|
||||
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
date to time
|
||||
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
|
||||
// values ("…Z") still parse via the ZonedDateTime fallback.
|
||||
val ldt = runCatching { LocalDateTime.parse(datetime) }
|
||||
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
|
||||
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
|
||||
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
|
||||
} catch (_: Exception) {
|
||||
datetime.take(10) to ""
|
||||
EventWhen(datetime.take(10), "", "")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ fun ItunesDownloadSheet(
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
// Open fully so the "Herunterladen" button is visible right away (it sits above the system
|
||||
// nav bar via navigationBarsPadding below) — no drag-up needed.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
||||
) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@@ -34,9 +35,25 @@ fun MiniPlayer(
|
||||
onTogglePlay: () -> Unit,
|
||||
onSkipPrev: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
lastSong: PrefsStore.LastPlayedSong? = null,
|
||||
lastSongCoverUrl: String? = null,
|
||||
onPlayLast: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val song = state.currentSong ?: return
|
||||
val song = state.currentSong
|
||||
|
||||
// If no active song but last song exists, show it in "resume" mode
|
||||
if (song == null) {
|
||||
val ls = lastSong ?: return
|
||||
LastSongMiniPlayer(
|
||||
lastSong = ls,
|
||||
coverUrl = lastSongCoverUrl,
|
||||
onTap = onTap,
|
||||
onPlay = onPlayLast ?: {},
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -55,78 +72,93 @@ fun MiniPlayer(
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
text = song.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = song.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
text = song.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Icon(Icons.Default.SkipPrevious, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
// Accent play/pause circle button
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onTogglePlay),
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Icon(Icons.Default.SkipNext, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
// Progress bar anchored to the bottom of the card
|
||||
if (state.durationMs > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent,
|
||||
trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastSongMiniPlayer(
|
||||
lastSong: PrefsStore.LastPlayedSong,
|
||||
coverUrl: String?,
|
||||
onTap: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = lastSong.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (lastSong.artist.isNotBlank()) {
|
||||
Text(
|
||||
text = lastSong.artist, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent.copy(alpha = 0.6f)).clickable(onClick = onPlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -26,7 +29,10 @@ import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationSheet(onDismiss: () -> Unit) {
|
||||
fun NotificationSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigate: (AppNotification) -> Unit = {},
|
||||
) {
|
||||
val items by NotificationStore.items.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
|
||||
@@ -73,7 +79,11 @@ fun NotificationSheet(onDismiss: () -> Unit) {
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
) {
|
||||
items(items, key = { it.id }) { n ->
|
||||
NotifCard(n, onDelete = { NotificationStore.remove(n.id) })
|
||||
NotifCard(
|
||||
n = n,
|
||||
onDelete = { NotificationStore.remove(n.id) },
|
||||
onNavigate = { onNavigate(n); onDismiss() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,13 +91,23 @@ fun NotificationSheet(onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
|
||||
private fun NotifCard(n: AppNotification, onDelete: () -> Unit, onNavigate: () -> Unit) {
|
||||
val hasNav = n.status == "done" && n.navType.isNotBlank()
|
||||
val navLabel = when (n.navType) {
|
||||
"playlist" -> "Öffnen →"
|
||||
else -> "Zum Album →"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = if (hasNav) 6.dp else 12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -144,10 +164,35 @@ private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNav) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 64.dp, end = 16.dp, bottom = 12.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsAccent.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onNavigate)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
navLabel,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward, null,
|
||||
tint = NsAccent, modifier = Modifier.size(13.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = NsBorder.copy(alpha = 0.4f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
@@ -23,6 +29,7 @@ import com.novasonic.app.ui.theme.*
|
||||
fun PlaylistPickerSheet(
|
||||
song: Song,
|
||||
loadPlaylists: suspend () -> List<Playlist>,
|
||||
coverUrl: (String?) -> String?,
|
||||
onAdd: (Playlist) -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -65,15 +72,19 @@ fun PlaylistPickerSheet(
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
|
||||
placeholder = { Text("Neue Playlist…", color = NsOnSurface2, fontSize = 14.sp) },
|
||||
leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null, tint = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsBorder,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
focusedContainerColor = NsSurface2,
|
||||
unfocusedContainerColor = NsSurface2,
|
||||
),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
@@ -117,8 +128,22 @@ fun PlaylistPickerSheet(
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val cv = coverUrl(pl.coverArt)
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (cv != null) {
|
||||
AsyncImage(
|
||||
model = cv, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
pl.name,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
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 coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PreviewState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
/**
|
||||
* Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview
|
||||
* clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore
|
||||
* screen it was started on.
|
||||
*/
|
||||
@Composable
|
||||
fun PreviewBar(
|
||||
state: PreviewState,
|
||||
onToggle: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 2.dp)
|
||||
.shadow(10.dp, RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = state.artworkUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface),
|
||||
)
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (state.artist.isNotBlank()) {
|
||||
Text(
|
||||
text = state.artist, fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (state.isPlaying) "Pause" else "Abspielen",
|
||||
tint = Color.White, modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ fun SongContextSheet(
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
// Open fully expanded so all options (incl. "Herunterladen" at the bottom) are reachable
|
||||
// right away — no extra drag-up needed.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -34,6 +36,9 @@ fun SongRow(
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onMore: (() -> Unit)? = null,
|
||||
) {
|
||||
// Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere.
|
||||
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||
val effectiveStarred = starOverrides[song.id] ?: starred
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -113,9 +118,9 @@ fun SongRow(
|
||||
if (onStarToggle != null) {
|
||||
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
contentDescription = null,
|
||||
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||
tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.AbsChapter
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibrary
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.player.AbsPlayState
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private fun String.stripHtml(): String =
|
||||
replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
// ── Library list ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsScreen(
|
||||
absClient: AbsClient,
|
||||
onLibraryClick: (AbsLibrary) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
val result = runCatching { absClient.getLibraries() }
|
||||
if (result.isSuccess) {
|
||||
libraries = result.getOrDefault(emptyList())
|
||||
} else {
|
||||
error = result.exceptionOrNull()?.message ?: "Fehler"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
|
||||
Text(
|
||||
"Audiobookshelf",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
libraries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Bibliotheken gefunden", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(libraries) { lib ->
|
||||
LibraryRow(library = lib, onClick = { onLibraryClick(lib) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Combined books screen (skips library list, auto-loads first library) ─────
|
||||
|
||||
@Composable
|
||||
fun AbsBooksScreen(
|
||||
absClient: AbsClient,
|
||||
onItemClick: (AbsLibraryItem) -> Unit,
|
||||
) {
|
||||
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
|
||||
var selectedLibIdx by remember { mutableIntStateOf(0) }
|
||||
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
// Initial load: fetch libraries + first library's items
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
|
||||
libraries = libs
|
||||
val first = libs.firstOrNull() ?: run { isLoading = false; return@LaunchedEffect }
|
||||
val r = runCatching { absClient.getLibraryItems(first.id).results }
|
||||
items = r.getOrDefault(emptyList())
|
||||
error = r.exceptionOrNull()?.message
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// Reload when user switches library tab
|
||||
LaunchedEffect(selectedLibIdx) {
|
||||
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
|
||||
query = ""
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// Debounced search — 400ms after last keystroke
|
||||
LaunchedEffect(query) {
|
||||
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
|
||||
if (query.isBlank()) {
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(400)
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.searchLibraryItems(lib.id, query) }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
|
||||
Text(
|
||||
"Hörbücher",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Suchen…", fontFamily = Outfit, color = NsOnSurface3) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) {{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Löschen", tint = NsOnSurface2)
|
||||
}
|
||||
}} else null,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsBorder,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = NsSurface,
|
||||
unfocusedContainerColor = NsSurface,
|
||||
),
|
||||
)
|
||||
|
||||
// Library switcher — only shown when more than one library exists
|
||||
if (libraries.size > 1) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
libraries.forEachIndexed { idx, lib ->
|
||||
val sel = idx == selectedLibIdx
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (sel) NsAccent else NsSurface)
|
||||
.clickable { selectedLibIdx = idx }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
lib.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = if (sel) NsBackground else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (query.isNotBlank()) "Keine Ergebnisse für \"$query\"" else "Keine Bücher gefunden",
|
||||
fontFamily = Outfit, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(items) { item ->
|
||||
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(NsAccent.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Book, contentDescription = null, tint = NsAccent, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = library.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = library.mediaType.replaceFirstChar { it.uppercase() },
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library items list ────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsLibraryScreen(
|
||||
absClient: AbsClient,
|
||||
library: AbsLibrary,
|
||||
onItemClick: (AbsLibraryItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(library.id) {
|
||||
isLoading = true
|
||||
val result = runCatching { absClient.getLibraryItems(library.id).results }
|
||||
if (result.isSuccess) {
|
||||
items = result.getOrDefault(emptyList())
|
||||
} else {
|
||||
error = result.exceptionOrNull()?.message ?: "Fehler"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
library.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items) { item ->
|
||||
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient, onClick: () -> Unit) {
|
||||
val meta = item.media.metadata
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(item.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta.title ?: item.id,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!meta.authorName.isNullOrBlank()) {
|
||||
Text(
|
||||
text = meta.authorName, fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val durationMin = (item.media.duration / 60).toInt()
|
||||
if (durationMin > 0) {
|
||||
Text(
|
||||
text = "${durationMin / 60}h ${durationMin % 60}m",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Book detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsBookDetailScreen(
|
||||
absClient: AbsClient,
|
||||
libraryItem: AbsLibraryItem,
|
||||
absPlayerController: AbsPlayerController,
|
||||
onPlay: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var chapters by remember { mutableStateOf(libraryItem.media.chapters) }
|
||||
var progress by remember { mutableStateOf<Double?>(null) }
|
||||
var isLoadingDetail by remember { mutableStateOf(libraryItem.media.chapters.isEmpty()) }
|
||||
var isStartingPlay by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(libraryItem.id) {
|
||||
if (chapters.isEmpty()) {
|
||||
isLoadingDetail = true
|
||||
chapters = runCatching { absClient.getItemChapters(libraryItem.id) }.getOrDefault(emptyList())
|
||||
isLoadingDetail = false
|
||||
}
|
||||
progress = runCatching { absClient.getProgress(libraryItem.id)?.currentTime }.getOrNull()
|
||||
}
|
||||
|
||||
val meta = libraryItem.media.metadata
|
||||
val duration = libraryItem.media.duration
|
||||
val progressPct = if (duration > 0 && (progress ?: 0.0) > 0) ((progress!! / duration) * 100).toInt() else 0
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
meta.title ?: "Hörbuch", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Header
|
||||
item(key = "header") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(libraryItem.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)).background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta.title ?: "Unbekannt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
)
|
||||
if (!meta.authorName.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = meta.authorName, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
if (duration > 0) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val h = (duration / 3600).toInt()
|
||||
val m = ((duration % 3600) / 60).toInt()
|
||||
Text(
|
||||
text = if (h > 0) "${h}h ${m}m" else "${m}m",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
if (progressPct in 1..98) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progressPct / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
|
||||
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.2f),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$progressPct% gehört",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Play button
|
||||
item(key = "play") {
|
||||
Button(
|
||||
onClick = {
|
||||
isStartingPlay = true
|
||||
scope.launch {
|
||||
runCatching { absPlayerController.playItem(libraryItem, absClient) }
|
||||
isStartingPlay = false
|
||||
onPlay()
|
||||
}
|
||||
},
|
||||
enabled = !isStartingPlay,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (isStartingPlay) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (progressPct > 5) "Weiterhören" else "Abspielen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description (strip HTML tags from ABS markdown/HTML content)
|
||||
if (!meta.description.isNullOrBlank()) {
|
||||
item(key = "desc") {
|
||||
Text(
|
||||
text = meta.description.stripHtml(),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
if (chapters.isNotEmpty()) {
|
||||
item(key = "chHeader") {
|
||||
Text(
|
||||
"Kapitel (${chapters.size})",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp, color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
itemsIndexed(chapters, key = { idx, _ -> "ch_$idx" }) { idx, ch ->
|
||||
ChapterDetailRow(
|
||||
chapter = ch,
|
||||
index = idx + 1,
|
||||
onTap = {
|
||||
scope.launch {
|
||||
isStartingPlay = true
|
||||
runCatching {
|
||||
absPlayerController.playItem(libraryItem, absClient)
|
||||
absPlayerController.seekToGlobal(ch.start)
|
||||
}
|
||||
isStartingPlay = false
|
||||
onPlay()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else if (isLoadingDetail) {
|
||||
item(key = "chLoading") {
|
||||
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChapterDetailRow(chapter: AbsChapter, index: Int, onTap: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$index",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp, color = NsAccent,
|
||||
modifier = Modifier.width(28.dp),
|
||||
)
|
||||
Text(
|
||||
text = chapter.title.ifBlank { "Kapitel $index" },
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = fmtSecs(chapter.start),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ABS Now Playing overlay ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsNowPlayingScreen(
|
||||
absPlayerController: AbsPlayerController,
|
||||
absClient: AbsClient,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state by absPlayerController.state.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(NsBackground)
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures { _, delta -> if (delta > 80f) onDismiss() }
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Schließen", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Wird abgespielt", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.weight(1f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.width(48.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Cover
|
||||
AsyncImage(
|
||||
model = state.item?.let { absClient.coverUrl(it.id) },
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Title / Author / Chapter
|
||||
val meta = state.item?.media?.metadata
|
||||
Text(
|
||||
text = meta?.title ?: "Unbekannt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = NsOnSurface,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!meta?.authorName.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = meta?.authorName ?: "",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
val currentChapter = state.chapters.getOrNull(state.chapterIdx)
|
||||
if (currentChapter != null && currentChapter.title.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = currentChapter.title,
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent, textAlign = TextAlign.Center,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Progress bar
|
||||
val chWin = chapterWindow(state)
|
||||
val dispCur = if (chWin != null) (state.currentTime - chWin.first).coerceAtLeast(0.0) else state.currentTime
|
||||
val dispDur = if (chWin != null) chWin.second - chWin.first else state.duration
|
||||
val progress = if (dispDur > 0) (dispCur / dispDur).coerceIn(0.0, 1.0).toFloat() else 0f
|
||||
|
||||
Slider(
|
||||
value = progress,
|
||||
onValueChange = { v ->
|
||||
val globalTarget = if (chWin != null) chWin.first + v * (chWin.second - chWin.first)
|
||||
else v * state.duration
|
||||
absPlayerController.seekToGlobal(globalTarget)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = NsAccent, activeTrackColor = NsAccent,
|
||||
inactiveTrackColor = NsAccent.copy(alpha = 0.2f),
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(fmtSecs(dispCur), fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
Text("-${fmtSecs(dispDur - dispCur)}", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Controls
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconButton(onClick = { absPlayerController.prevChapter() }) {
|
||||
Icon(Icons.Default.SkipPrevious, contentDescription = "Voriges Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.skipSecs(-30.0) }) {
|
||||
Icon(Icons.Default.Replay30, contentDescription = "-30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier.size(64.dp).clip(CircleShape).background(NsAccent)
|
||||
.clickable { absPlayerController.togglePlay() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null, tint = NsBackground, modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.skipSecs(30.0) }) {
|
||||
Icon(Icons.Default.Forward30, contentDescription = "+30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.nextChapter() }) {
|
||||
Icon(Icons.Default.SkipNext, contentDescription = "Nächstes Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Speed + Sleep + Chapter track row
|
||||
AbsBottomControls(state = state, absPlayerController = absPlayerController)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Chapter list
|
||||
if (state.chapters.isNotEmpty()) {
|
||||
AbsChapterList(
|
||||
state = state,
|
||||
absPlayerController = absPlayerController,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize().background(NsBackground.copy(alpha = 0.7f)), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
|
||||
var showSpeedPicker by remember { mutableStateOf(false) }
|
||||
var showSleepPicker by remember { mutableStateOf(false) }
|
||||
var showCustomSleepDialog by remember { mutableStateOf(false) }
|
||||
var customMinutesText by remember { mutableStateOf("") }
|
||||
|
||||
val speedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
|
||||
val sleepOptions = listOf(5 to "5 min", 10 to "10 min", 15 to "15 min", 30 to "30 min", 60 to "1 Stunde")
|
||||
|
||||
if (showCustomSleepDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCustomSleepDialog = false },
|
||||
title = { Text("Sleep Timer", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text("Minuten eingeben:", fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface2)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = customMinutesText,
|
||||
onValueChange = { v -> if (v.all { it.isDigit() } && v.length <= 4) customMinutesText = v },
|
||||
label = { Text("Minuten", fontFamily = Outfit) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3, focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val mins = customMinutesText.toIntOrNull()
|
||||
if (mins != null && mins > 0) absPlayerController.setSleepTimer(mins)
|
||||
showCustomSleepDialog = false
|
||||
}) { Text("Starten", color = NsAccent, fontFamily = Outfit) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCustomSleepDialog = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Speed
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
|
||||
Text(
|
||||
"${state.speed}×",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (state.speed != 1f) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSpeedPicker,
|
||||
onDismissRequest = { showSpeedPicker = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
speedOptions.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
|
||||
onClick = { absPlayerController.setSpeed(s); showSpeedPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep timer
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
val sleepRem = state.sleepRemainingSecs
|
||||
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
|
||||
Icon(
|
||||
Icons.Default.Bedtime, contentDescription = "Sleep Timer",
|
||||
tint = if (sleepRem != null) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (sleepRem != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
val m = sleepRem / 60
|
||||
val s = (sleepRem % 60).toString().padStart(2, '0')
|
||||
Text("$m:$s", fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSleepPicker,
|
||||
onDismissRequest = { showSleepPicker = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
if (state.sleepRemainingSecs != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Abbrechen", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) },
|
||||
onClick = { absPlayerController.cancelSleep(); showSleepPicker = false },
|
||||
)
|
||||
}
|
||||
sleepOptions.forEach { (mins, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Benutzerdefiniert…", fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { showSleepPicker = false; customMinutesText = ""; showCustomSleepDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chapter track mode toggle
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
|
||||
Icon(
|
||||
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
|
||||
tint = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"Kapitel", fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsChapterList(
|
||||
state: AbsPlayState,
|
||||
absPlayerController: AbsPlayerController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(state.chapterIdx) {
|
||||
if (state.chapters.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.chapterIdx.coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
"Kapitel",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
HorizontalDivider(color = NsBorder)
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(state.chapters) { idx, ch ->
|
||||
val isActive = idx == state.chapterIdx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isActive) NsAccent.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.clickable { absPlayerController.seekToChapter(idx) }
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
"${idx + 1}",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
color = if (isActive) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.width(28.dp),
|
||||
)
|
||||
Text(
|
||||
ch.title.ifBlank { "Kapitel ${idx + 1}" },
|
||||
fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = if (isActive) NsAccent else NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
fmtSecs(ch.start),
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = NsBorder.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun chapterWindow(state: AbsPlayState): Pair<Double, Double>? {
|
||||
if (!state.chapterTrackMode || state.chapters.isEmpty()) return null
|
||||
val ch = state.chapters.getOrNull(state.chapterIdx) ?: return null
|
||||
val next = state.chapters.getOrNull(state.chapterIdx + 1)
|
||||
val end = next?.start ?: ch.end.takeIf { it > ch.start } ?: state.duration
|
||||
if (end <= ch.start) return null
|
||||
return Pair(ch.start, end)
|
||||
}
|
||||
|
||||
internal fun fmtSecs(secs: Double): String {
|
||||
val s = secs.toLong().coerceAtLeast(0)
|
||||
val h = s / 3600
|
||||
val m = (s % 3600) / 60
|
||||
val sec = s % 60
|
||||
return if (h > 0) "$h:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}"
|
||||
else "$m:${sec.toString().padStart(2, '0')}"
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
@@ -45,6 +47,8 @@ fun AlbumScreen(
|
||||
) {
|
||||
val vm: AlbumViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -173,6 +177,30 @@ fun AlbumScreen(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(
|
||||
when {
|
||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
else -> NsAccent.copy(alpha = 0.55f)
|
||||
}
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
@@ -10,16 +10,24 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -35,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.fmtFanCount
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.component.ConcertsSheet
|
||||
@@ -42,6 +51,7 @@ import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
@@ -57,10 +67,19 @@ fun ArtistDetailScreen(
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||
|
||||
// Which "all albums" section is expanded (by title), or null. rememberSaveable so it survives
|
||||
// navigating into an album and back — otherwise back would land on the collapsed artist page.
|
||||
var openAllAlbums by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showAllAlbums = openAllAlbums != null
|
||||
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
|
||||
openAllAlbums = null
|
||||
}
|
||||
|
||||
LaunchedEffect(state.pendingShufflePlay) {
|
||||
state.pendingShufflePlay?.let { songs ->
|
||||
onPlay(songs, 0)
|
||||
@@ -76,6 +95,7 @@ fun ArtistDetailScreen(
|
||||
artistName = artist.name,
|
||||
events = state.events,
|
||||
onDismiss = vm::dismissEventsSheet,
|
||||
eventsError = state.eventsError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -94,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) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
@@ -120,6 +156,7 @@ fun ArtistDetailScreen(
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -149,6 +186,39 @@ fun ArtistDetailScreen(
|
||||
// Album categories (computed once iTunes data arrives)
|
||||
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
||||
|
||||
// Rebuild the overlay entries from the (surviving) categorized albums for whichever section is
|
||||
// open. Derived — not stored — so it's correct after returning from an album.
|
||||
val allAlbumEntries: List<AllAlbumEntry> = when (openAllAlbums) {
|
||||
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||
}.orEmpty()
|
||||
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||
AllAlbumEntry(
|
||||
name = nd.name, year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { vm.loadPartialAlbum(nd, ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||
AllAlbumEntry(
|
||||
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||
onClick = { vm.loadSubAlbum(ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (showAllAlbums) {
|
||||
AllAlbumsOverlay(
|
||||
title = openAllAlbums ?: "",
|
||||
entries = allAlbumEntries,
|
||||
onBack = { openAllAlbums = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
@@ -168,7 +238,10 @@ fun ArtistDetailScreen(
|
||||
|
||||
// Hero card
|
||||
item {
|
||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||
// Navidrome/Last.fm image is the wrong person).
|
||||
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: coverUrl(artist.coverArt)
|
||||
Box(
|
||||
Modifier
|
||||
@@ -184,6 +257,20 @@ fun ArtistDetailScreen(
|
||||
contentScale = ContentScale.Crop,
|
||||
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(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
@@ -192,13 +279,21 @@ fun ArtistDetailScreen(
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
|
||||
)
|
||||
state.fanCount?.takeIf { it > 0 }?.let {
|
||||
Text(
|
||||
"${fmtFanCount(it)} Fans",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -232,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
|
||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||
?: emptyList()
|
||||
if (similarArtists.isNotEmpty()) {
|
||||
if (usePinnedRelated) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
|
||||
// if the artist is in the library, else say so.
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val id = vm.libraryArtistId(rel.artistName)
|
||||
if (id != null && onArtistClick != null) onArtistClick(id)
|
||||
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.artworkUrl100,
|
||||
contentDescription = rel.artistName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (similarArtists.isNotEmpty()) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
@@ -280,7 +416,7 @@ fun ArtistDetailScreen(
|
||||
// Navidrome top songs
|
||||
if (state.topSongs.isNotEmpty()) {
|
||||
item { SectionLabel("Top Songs") }
|
||||
itemsIndexed(state.topSongs, key = { _, s -> "top_${s.id}" }) { idx, song ->
|
||||
itemsIndexed(state.topSongs, key = { idx, s -> "top_${idx}_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
@@ -297,7 +433,7 @@ fun ArtistDetailScreen(
|
||||
// ── iTunes: Weltweit populär ──────────────────────────────────────────
|
||||
if (state.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär") }
|
||||
itemsIndexed(state.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
itemsIndexed(state.iTracks, key = { idx, t -> "itrack_${idx}_${t.trackId ?: 0L}" }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
@@ -315,7 +451,7 @@ fun ArtistDetailScreen(
|
||||
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
|
||||
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
@@ -324,7 +460,11 @@ fun ArtistDetailScreen(
|
||||
// ── Album sections (categorized once iTunes data is ready) ────────────
|
||||
if (cats != null) {
|
||||
if (cats.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", cats.downloaded.size) }
|
||||
item {
|
||||
ArtistSectionHeader("In Library", cats.downloaded.size,
|
||||
onShowAll = if (cats.downloaded.size > 4) ({ openAllAlbums = "In Library" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -344,7 +484,11 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
|
||||
if (cats.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", cats.partial.size) }
|
||||
item {
|
||||
ArtistSectionHeader("Unvollständig", cats.partial.size,
|
||||
onShowAll = if (cats.partial.size > 4) ({ openAllAlbums = "Unvollständig" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -367,7 +511,11 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
|
||||
if (cats.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size) }
|
||||
item {
|
||||
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
|
||||
onShowAll = if (cats.notDownloaded.size > 4) ({ openAllAlbums = "Nicht heruntergeladen" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -460,3 +608,192 @@ private fun SectionLabel(title: String) {
|
||||
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal data class AllAlbumEntry(
|
||||
val name: String,
|
||||
val year: String?,
|
||||
val artUrl: String?,
|
||||
val badge: String? = null,
|
||||
val dimmed: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
val onDownload: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun AllAlbumsOverlay(
|
||||
title: String,
|
||||
entries: List<AllAlbumEntry>,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(query, entries) {
|
||||
if (query.isBlank()) entries
|
||||
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(NsBackground),
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 12.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${filtered.size}",
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(0.6f),
|
||||
)
|
||||
}
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) { {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(filtered.size, key = { idx -> "${idx}_${filtered[idx].name}${filtered[idx].year ?: ""}" }) { idx ->
|
||||
val entry = filtered[idx]
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
AlbumThumb(
|
||||
name = entry.name,
|
||||
year = entry.year,
|
||||
artUrl = entry.artUrl,
|
||||
badge = entry.badge,
|
||||
dimmed = entry.dimmed,
|
||||
onClick = entry.onClick,
|
||||
onDownload = entry.onDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -49,6 +50,8 @@ fun ExploreScreen(
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||
onOverlayBackChange: ((() -> Unit)?) -> Unit = {},
|
||||
onOpenImporter: () -> Unit = {},
|
||||
) {
|
||||
val vm: ExploreViewModel = viewModel()
|
||||
@@ -56,10 +59,31 @@ fun ExploreScreen(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// BackHandlers for state-based navigation (must be unconditional)
|
||||
BackHandler(enabled = state.artistDetail != null && state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.artistDetail != null && state.albumTracks != null && state.partialAlbum == null) { vm.dismissAlbum() }
|
||||
BackHandler(enabled = state.artistDetail != null && state.albumTracks == null && state.partialAlbum == null) { vm.closeArtistDetail() }
|
||||
// Overlay back is handled at the app root (see AppNavigation.exploreOverlayBack): a BackHandler
|
||||
// inside this NavHost destination loses back priority to the NavHost, which pops Erkunden → Home.
|
||||
// Report the current overlay's close action up; clear it when this screen leaves composition
|
||||
// (e.g. drilling into a real album route) so that screen's own back works normally.
|
||||
val overlayKind = when {
|
||||
state.artistDetail != null && state.partialAlbum != null -> 5 // artist → partial album
|
||||
state.artistDetail != null && state.albumTracks != null -> 4 // artist → iTunes album
|
||||
state.artistDetail != null && state.artistAllAlbums != null -> 3 // artist → all-albums grid
|
||||
state.artistDetail != null -> 2 // iTunes artist
|
||||
state.albumTracks != null -> 1 // album straight from search
|
||||
else -> 0
|
||||
}
|
||||
DisposableEffect(overlayKind) {
|
||||
onOverlayBackChange(
|
||||
when (overlayKind) {
|
||||
5 -> vm::dismissPartialAlbum
|
||||
4 -> vm::dismissAlbum
|
||||
3 -> vm::closeArtistAllAlbums
|
||||
2 -> vm::closeArtistDetail
|
||||
1 -> vm::dismissAlbum
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
onDispose { onOverlayBackChange(null) }
|
||||
}
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
@@ -91,6 +115,7 @@ fun ExploreScreen(
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
}
|
||||
albumTracks != null && albumItem != null -> {
|
||||
@@ -117,28 +142,44 @@ fun ExploreScreen(
|
||||
onItunesAlbumOpen = vm::loadAlbum,
|
||||
onItunesTrackPreview = vm::togglePreview,
|
||||
previewingId = state.previewingId,
|
||||
allAlbumsOpen = state.artistAllAlbums,
|
||||
onOpenAllAlbums = vm::openArtistAllAlbums,
|
||||
onCloseAllAlbums = vm::closeArtistAllAlbums,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
|
||||
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = ExploreTab.values()[pagerState.settledPage]
|
||||
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
|
||||
// half-way point), not only once the swipe settles — that lag read as "not smooth".
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.LIBRARY,
|
||||
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||
text = { Text("Bibliothek", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.GLOBAL,
|
||||
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||
text = { Text("Erkunden", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
@@ -205,17 +246,28 @@ fun ExploreScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────
|
||||
when (state.tab) {
|
||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
||||
// ── Content (swipeable) ────────────────────────────────────────────────
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
|
||||
// builds on first drag.
|
||||
beyondViewportPageCount = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { page ->
|
||||
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
|
||||
// vertically centered in the page.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (page) {
|
||||
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
|
||||
query = state.query,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onSongPlay = onSongPlay,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
||||
else -> GlobalExploreContent(
|
||||
state = state,
|
||||
vm = vm,
|
||||
coverUrl = coverUrl,
|
||||
@@ -226,6 +278,8 @@ fun ExploreScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library (local Navidrome) search ─────────────────────────────────────────
|
||||
@@ -238,6 +292,7 @@ private fun LibrarySearchContent(
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||
) {
|
||||
// Reuse SearchScreen's ViewModel
|
||||
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
||||
@@ -262,7 +317,7 @@ private fun LibrarySearchContent(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
LazyColumn(state = vm.listState) {
|
||||
val artists = state.searchResult?.artist ?: emptyList()
|
||||
val albums = state.searchResult?.album ?: emptyList()
|
||||
val songs = state.searchResult?.song ?: emptyList()
|
||||
@@ -283,7 +338,11 @@ private fun LibrarySearchContent(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(artists, key = { it.id }) { a ->
|
||||
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt)) { onArtistClick(a) }
|
||||
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
|
||||
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
|
||||
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
|
||||
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
|
||||
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -313,7 +372,10 @@ private fun LibrarySearchContent(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(songs, idx) },
|
||||
starred = song.starred != null,
|
||||
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||
onPlay = { onSongPlay(listOf(song), 0) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -399,7 +461,7 @@ private fun GlobalExploreContent(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
LazyColumn(state = vm.searchListState) {
|
||||
if (state.error != null) {
|
||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)) }
|
||||
@@ -415,6 +477,7 @@ private fun GlobalExploreContent(
|
||||
ArtistCard(
|
||||
name = a.artistName ?: "",
|
||||
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
|
||||
) { vm.openArtistDetail(a) }
|
||||
}
|
||||
}
|
||||
@@ -590,28 +653,47 @@ private fun ItunesTrackRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
|
||||
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Person, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -77,6 +78,7 @@ fun HomeScreen(
|
||||
onGenrePlay: ((Genre) -> Unit)? = null,
|
||||
onShowAllGenres: (() -> Unit)? = null,
|
||||
onBellClick: () -> Unit = {},
|
||||
onSettingsClick: () -> Unit = {},
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
val vm: HomeViewModel = viewModel()
|
||||
@@ -122,6 +124,9 @@ fun HomeScreen(
|
||||
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, "Einstellungen", tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +157,12 @@ fun HomeScreen(
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
// Hero card — matches PWA .home-hero (170px height)
|
||||
// Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
|
||||
state.heroAlbum?.let { hero ->
|
||||
item {
|
||||
HeroCard(
|
||||
album = hero,
|
||||
label = state.heroLabel,
|
||||
coverUrl = coverUrl(hero),
|
||||
onPlay = { onAlbumPlay(hero) },
|
||||
onClick = { onAlbumClick(hero) },
|
||||
@@ -200,10 +206,10 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.frequentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Meistgespielt") }
|
||||
if (state.recentlyPlayedAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Zuletzt gehört") }
|
||||
item {
|
||||
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
@@ -211,6 +217,13 @@ fun HomeScreen(
|
||||
item { SectionHeader("Entdecken") }
|
||||
item {
|
||||
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.frequentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Meistgespielt") }
|
||||
item {
|
||||
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -221,7 +234,7 @@ fun HomeScreen(
|
||||
// background = blur(8px) brightness(.45) + horizontal gradient overlay
|
||||
// cover = 100x100, border-radius:9px
|
||||
@Composable
|
||||
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
|
||||
private fun HeroCard(album: Album, label: String, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -230,25 +243,20 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
// Blurred background image — matches .home-hero-bg
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(16.dp),
|
||||
)
|
||||
// Gradient overlay — matches .home-hero-overlay
|
||||
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
|
||||
)
|
||||
)
|
||||
// Content row
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
@@ -258,10 +266,13 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
// Label — matches .home-hero-label
|
||||
Text(
|
||||
text = "AKTUELL",
|
||||
text = label,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
@@ -289,8 +300,8 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Play button — matches .home-hero-play (42px circle, accent bg)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
@@ -400,7 +411,6 @@ private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumC
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -205,7 +207,12 @@ fun ImporterScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
itemsIndexed(r.tracks) { idx, track ->
|
||||
ImportTrackRow(idx + 1, track)
|
||||
ImportTrackRow(
|
||||
num = idx + 1,
|
||||
track = track,
|
||||
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +265,12 @@ private fun JobProgress(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
private fun ImportTrackRow(
|
||||
num: Int,
|
||||
track: ImportTrack,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
@@ -272,11 +284,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!track.artistName.isNullOrBlank()) {
|
||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
// 30s preview — only when the source provided a clip
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
@@ -29,12 +34,15 @@ import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.fmtFanCount
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||
|
||||
private fun normAlbum(s: String?): String =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
|
||||
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
data class ArtistAlbumCategories(
|
||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||
@@ -52,6 +60,9 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
||||
|
||||
for (ia in uniqueIAlbums) {
|
||||
val iName = normAlbum(ia.collectionName)
|
||||
// Deezer's /artist/{id}/albums omits nb_tracks, so trackCount is usually unknown (0) here.
|
||||
// Only mark "Unvollständig" when we KNOW the album has more tracks than we own — an unknown
|
||||
// total counts as complete, never a false "1 / 0".
|
||||
val iCount = ia.trackCount ?: 0
|
||||
val match = ndAlbums.find { a ->
|
||||
val n = normAlbum(a.name)
|
||||
@@ -59,11 +70,15 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
||||
}
|
||||
when {
|
||||
match == null -> notDl.add(ia)
|
||||
iCount > 0 && match.songCount >= iCount -> {
|
||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
|
||||
// above only catches identical ids, so also skip an owned album we've already classified —
|
||||
// otherwise it shows 2-3x ("PIXELATED KISSES").
|
||||
match.id in usedNdIds -> Unit
|
||||
iCount > match.songCount -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
}
|
||||
else -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +106,48 @@ fun ItunesArtistScreen(
|
||||
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
||||
onItunesTrackPreview: (ItunesItem) -> Unit,
|
||||
previewingId: Long?,
|
||||
allAlbumsOpen: String?,
|
||||
onOpenAllAlbums: (String) -> Unit,
|
||||
onCloseAllAlbums: () -> Unit,
|
||||
) {
|
||||
val artist = artistState.artist
|
||||
val cats = if (!artistState.isLoading)
|
||||
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
||||
else null
|
||||
|
||||
// "All albums" state is hoisted (ExploreViewModel.artistAllAlbums) so it survives an album
|
||||
// drill-down and back; entries are re-derived from cats for whichever section is open.
|
||||
val allAlbumEntries: List<AllAlbumEntry> = when (allAlbumsOpen) {
|
||||
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||
}.orEmpty()
|
||||
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||
AllAlbumEntry(
|
||||
name = nd.name, year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { onPartialAlbumOpen(nd, ia) }, onDownload = { onDownload(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||
AllAlbumEntry(
|
||||
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||
onClick = { onItunesAlbumOpen(ia) }, onDownload = { onDownload(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (allAlbumsOpen != null) {
|
||||
AllAlbumsOverlay(
|
||||
title = allAlbumsOpen,
|
||||
entries = allAlbumEntries,
|
||||
onBack = onCloseAllAlbums,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Pick backdrop: first Navidrome album cover, else first iTunes album art
|
||||
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
|
||||
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
@@ -144,6 +195,10 @@ fun ItunesArtistScreen(
|
||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
||||
}
|
||||
artist.fanCount?.takeIf { it > 0 }?.let {
|
||||
Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = Color.White.copy(0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +245,7 @@ fun ItunesArtistScreen(
|
||||
|
||||
// iTunes top tracks
|
||||
if (artistState.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
|
||||
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
|
||||
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
@@ -204,7 +259,11 @@ fun ItunesArtistScreen(
|
||||
// ── Downloaded albums ─────────────────────────────────────────────
|
||||
cats?.let { c ->
|
||||
if (c.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", c.downloaded.size) }
|
||||
item {
|
||||
ArtistSectionHeader("In Library", c.downloaded.size,
|
||||
onShowAll = if (c.downloaded.size > 4) ({ onOpenAllAlbums("In Library") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -225,7 +284,11 @@ fun ItunesArtistScreen(
|
||||
|
||||
// ── Partial albums ────────────────────────────────────────────
|
||||
if (c.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", c.partial.size) }
|
||||
item {
|
||||
ArtistSectionHeader("Unvollständig", c.partial.size,
|
||||
onShowAll = if (c.partial.size > 4) ({ onOpenAllAlbums("Unvollständig") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -248,7 +311,11 @@ fun ItunesArtistScreen(
|
||||
|
||||
// ── Not downloaded ────────────────────────────────────────────
|
||||
if (c.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size) }
|
||||
item {
|
||||
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
|
||||
onShowAll = if (c.notDownloaded.size > 4) ({ onOpenAllAlbums("Nicht heruntergeladen") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -384,7 +451,7 @@ internal fun ItunesArtistTrackRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArtistSectionHeader(title: String, count: Int? = null) {
|
||||
internal fun ArtistSectionHeader(title: String, count: Int? = null, onShowAll: (() -> Unit)? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
|
||||
@@ -399,5 +466,13 @@ internal fun ArtistSectionHeader(title: String, count: Int? = null) {
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp))
|
||||
}
|
||||
if (onShowAll != null) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"Alle anzeigen",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
modifier = Modifier.clickable(onClick = onShowAll),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,29 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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
|
||||
@@ -27,10 +39,20 @@ import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumSort
|
||||
import com.novasonic.app.viewmodel.LibraryTab
|
||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||
import com.novasonic.app.viewmodel.OfflineViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import com.novasonic.app.api.model.Song
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
@@ -38,19 +60,49 @@ fun LibraryScreen(
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onOpenOffline: (DownloadGroupEntity) -> Unit,
|
||||
) {
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val offlineVm: OfflineViewModel = viewModel()
|
||||
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||
|
||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||
val downloadedAlbumIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.ALBUM && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
val downloadedPlaylistIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.PLAYLIST && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||
|
||||
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
|
||||
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
|
||||
// pager's current page so it moves WITH the swipe.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = LibraryTab.entries[pagerState.settledPage]
|
||||
if (t != state.tab) vm.selectTab(t)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
LibraryTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.tab == tab,
|
||||
selected = pagerState.currentPage == tab.ordinal,
|
||||
onClick = { vm.selectTab(tab) },
|
||||
text = {
|
||||
Text(
|
||||
@@ -58,6 +110,7 @@ fun LibraryScreen(
|
||||
LibraryTab.PLAYLISTS -> "Playlists"
|
||||
LibraryTab.ALBUMS -> "Alben"
|
||||
LibraryTab.ARTISTS -> "Künstler"
|
||||
LibraryTab.OFFLINE -> "Offline"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -69,33 +122,92 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
|
||||
// the online library is still loading or unreachable — only gate the network-backed tabs.
|
||||
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
} else when (pageTab) {
|
||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||
// Sort chips
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
items(AlbumSort.entries) { s ->
|
||||
FilterChip(
|
||||
selected = state.albumSort == s,
|
||||
onClick = { vm.setAlbumSort(s) },
|
||||
label = {
|
||||
Text(when (s) {
|
||||
AlbumSort.NEWEST -> "Neueste"
|
||||
AlbumSort.OLDEST -> "Älteste"
|
||||
AlbumSort.AZ -> "A-Z"
|
||||
}, fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
selectedLeadingIconColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
when (state.tab) {
|
||||
LibraryTab.ALBUMS -> LazyVerticalGrid(
|
||||
}
|
||||
val sortedAlbums = remember(state.albumsByNewest, state.albumsByName, state.albumSort) {
|
||||
when (state.albumSort) {
|
||||
AlbumSort.NEWEST -> state.albumsByNewest // already ordered by Navidrome add-date
|
||||
AlbumSort.AZ -> state.albumsByName // already ordered alphabetically
|
||||
AlbumSort.OLDEST -> state.albumsByName.sortedWith(
|
||||
compareBy<com.novasonic.app.api.model.Album> { it.year ?: Int.MAX_VALUE }
|
||||
.thenBy { it.name.lowercase() }
|
||||
)
|
||||
}
|
||||
}
|
||||
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
|
||||
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||
LazyVerticalGrid(
|
||||
state = albumGridState,
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.albums, key = { it.id }) { album ->
|
||||
items(sortedAlbums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
downloaded = album.id in downloadedAlbumIds,
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.ARTISTS -> LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
LibraryTab.ARTISTS -> {
|
||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||
val scope = rememberCoroutineScope()
|
||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(state.artistIndexes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
state.artistIndexes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
}
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
@@ -135,9 +247,81 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (letters.isNotEmpty()) {
|
||||
// Bubble showing active letter while dragging
|
||||
activeLetter?.let { letter ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.offset(x = (-44).dp)
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent),
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NsBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical letter strip
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(28.dp)
|
||||
.pointerInput(letters) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val y = event.changes.firstOrNull()?.position?.y ?: continue
|
||||
val idx = ((y / size.height) * letters.size)
|
||||
.toInt().coerceIn(0, letters.lastIndex)
|
||||
val letter = letters[idx]
|
||||
when (event.type) {
|
||||
PointerEventType.Press,
|
||||
PointerEventType.Move -> {
|
||||
event.changes.forEach { it.consume() }
|
||||
if (letter != activeLetter) {
|
||||
activeLetter = letter
|
||||
letterToIndex[letter]?.let { pos ->
|
||||
scope.launch { listState.scrollToItem(pos) }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> activeLetter = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
letters.forEach { letter ->
|
||||
Text(
|
||||
text = letter,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (letter == activeLetter) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (letter == activeLetter) NsAccent else NsOnSurface3,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -147,16 +331,241 @@ fun LibraryScreen(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
downloaded = pl.id in downloadedPlaylistIds,
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.OFFLINE -> {
|
||||
val groups = offlineGroups
|
||||
val storage by offlineVm.storageBytes.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.DownloadDone,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface3,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Noch nichts heruntergeladen",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 15.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Öffne ein Album und tippe auf ↓",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "%.1f MB gespeichert".format(storage / 1_000_000.0),
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
items(groups, key = { it.groupId }) { group ->
|
||||
OfflineGroupRow(
|
||||
group = group,
|
||||
coverUrl = coverUrl(group.coverArt),
|
||||
onOpen = { onOpenOffline(group) },
|
||||
onPlay = {
|
||||
scope.launch {
|
||||
val songs = offlineVm.songsForGroup(group.groupId)
|
||||
if (songs.isNotEmpty()) onPlay(songs, 0)
|
||||
}
|
||||
},
|
||||
onDelete = { groupToDelete = group },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end HorizontalPager
|
||||
} // end Column
|
||||
|
||||
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
containerColor = NsAccent,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "Neue Playlist", tint = NsBackground)
|
||||
}
|
||||
}
|
||||
} // end Box
|
||||
|
||||
// ── Delete offline group dialog ───────────────────────────────────────────
|
||||
groupToDelete?.let { group ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { groupToDelete = null },
|
||||
title = { Text("Löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("\"${group.name}\" aus dem Offline-Speicher entfernen?", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
offlineVm.deleteGroup(group.groupId)
|
||||
groupToDelete = null
|
||||
}) {
|
||||
Text("Löschen", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { groupToDelete = null }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create playlist dialog ────────────────────────────────────────────────
|
||||
if (showCreateDialog) {
|
||||
var nameInput by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateDialog = false },
|
||||
title = { Text("Neue Playlist", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = nameInput,
|
||||
onValueChange = { nameInput = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
vm.createPlaylist(nameInput)
|
||||
showCreateDialog = false
|
||||
},
|
||||
enabled = nameInput.isNotBlank(),
|
||||
) {
|
||||
Text("Erstellen", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreateDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) {
|
||||
private fun OfflineGroupRow(
|
||||
group: DownloadGroupEntity,
|
||||
coverUrl: String?,
|
||||
onOpen: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val isFullyDone = group.totalTracks > 0 && group.downloadedTracks >= group.totalTracks
|
||||
val subtitle = when {
|
||||
group.totalTracks == 0 -> "Ausstehend…"
|
||||
isFullyDone -> "${group.totalTracks} Titel · Vollständig"
|
||||
else -> "${group.downloadedTracks}/${group.totalTracks} Titel"
|
||||
}
|
||||
val typeLabel = when (group.type) {
|
||||
GroupType.ALBUM -> "Album"
|
||||
GroupType.PLAYLIST -> "Playlist"
|
||||
GroupType.SONG -> "Song"
|
||||
GroupType.FAVORITES -> "Favoriten"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onOpen)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = group.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = group.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$typeLabel · $subtitle",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlay, enabled = isFullyDone) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen",
|
||||
tint = if (isFullyDone) NsAccent else NsOnSurface3,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, "Löschen", tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (!isFullyDone && group.totalTracks > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { group.downloadedTracks.toFloat() / group.totalTracks },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
|
||||
color = NsAccent,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(
|
||||
name: String,
|
||||
subtitle: String,
|
||||
coverUrl: String?,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -165,6 +574,7 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
||||
.border(1.dp, NsBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
@@ -174,6 +584,8 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
@@ -189,3 +601,22 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small "downloaded" pill overlaid on a cover — the down-arrow-in-circle, Spotify-style. */
|
||||
@Composable
|
||||
fun DownloadedBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -33,9 +36,15 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -69,8 +78,26 @@ fun NowPlayingScreen(
|
||||
val song = state.currentSong
|
||||
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
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
LaunchedEffect(song?.id) {
|
||||
if (song != null) lyricsVm.loadForSong(song)
|
||||
}
|
||||
|
||||
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
|
||||
val topPad = maxOf(statusBarTop, 36.dp)
|
||||
val topPad = maxOf(statusBarTop + 16.dp, 52.dp)
|
||||
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
|
||||
|
||||
// Apple Music-style: cover scales up while playing
|
||||
@@ -83,20 +110,52 @@ fun NowPlayingScreen(
|
||||
label = "coverScale",
|
||||
)
|
||||
|
||||
// Extract dominant color from cover art for background tinting
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(72.dp),
|
||||
var dominantColor by remember(coverUrl) { mutableStateOf<Color?>(null) }
|
||||
LaunchedEffect(coverUrl) {
|
||||
dominantColor = null
|
||||
if (coverUrl.isNullOrBlank()) return@LaunchedEffect
|
||||
try {
|
||||
val loader = ImageLoader(context)
|
||||
val req = ImageRequest.Builder(context).data(coverUrl).allowHardware(false).size(80).build()
|
||||
val result = loader.execute(req)
|
||||
if (result is SuccessResult) {
|
||||
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
|
||||
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() }
|
||||
val swatch = palette.vibrantSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
|
||||
swatch?.rgb?.let { dominantColor = Color(it) }
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// Blend vibrant color toward darker for the background without losing the hue
|
||||
val rawBgColor = dominantColor?.let { c ->
|
||||
Color(
|
||||
red = (c.red * 0.55f).coerceIn(0f, 1f),
|
||||
green = (c.green * 0.55f).coerceIn(0f, 1f),
|
||||
blue = (c.blue * 0.55f).coerceIn(0f, 1f),
|
||||
)
|
||||
} ?: Color(0xFF0D0D1A)
|
||||
val animatedBgColor by animateColorAsState(
|
||||
targetValue = rawBgColor,
|
||||
animationSpec = tween(600),
|
||||
label = "npBg",
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// Dominant-color gradient — covers top 70% of screen
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color(0xCC000000), 1f to Color(0xF2000000))
|
||||
Brush.verticalGradient(
|
||||
0f to animatedBgColor,
|
||||
0.65f to animatedBgColor.copy(alpha = 0.5f),
|
||||
1f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// Light scrim for readability
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -118,7 +177,7 @@ fun NowPlayingScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
.padding(bottom = 28.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = onDismiss) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||
@@ -133,7 +192,7 @@ fun NowPlayingScreen(
|
||||
.padding(3.dp),
|
||||
) {
|
||||
NpTab.entries.forEach { t ->
|
||||
val sel = tab == t
|
||||
val sel = pagerState.currentPage == t.ordinal
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
@@ -165,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 ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.PLAYER) {
|
||||
NpTab.PLAYER -> {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
@@ -213,6 +282,16 @@ fun NowPlayingScreen(
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Order matches the web player: title → album → artists.
|
||||
if (!song?.album.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.album!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
}
|
||||
if (song != null && !song.artist.isNullOrBlank()) {
|
||||
// Prefer OpenSubsonic artists array (has IDs per contributor).
|
||||
// Fall back to splitting artist string by ';' (same as web app).
|
||||
@@ -226,14 +305,7 @@ fun NowPlayingScreen(
|
||||
)
|
||||
}.filter { it.name.isNotBlank() }
|
||||
|
||||
// De-duplicate: if multiple entries share the same ID (Navidrome
|
||||
// sometimes copies the primary artistId to all entries), only the
|
||||
// first occurrence keeps it — others become non-navigable.
|
||||
val seenIds = mutableSetOf<String>()
|
||||
val artistList = raw.map { a ->
|
||||
if (a.id.isNotBlank() && seenIds.add(a.id)) a
|
||||
else a.copy(id = "")
|
||||
}
|
||||
val artistList = raw
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -265,17 +337,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!song?.album.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.album!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onStarToggle != null && song != null) {
|
||||
val starred = song.starred != null
|
||||
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||
val starred = starOverrides[song.id] ?: (song.starred != null)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
@@ -283,7 +348,7 @@ fun NowPlayingScreen(
|
||||
.clickable(onClick = onStarToggle),
|
||||
) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
null,
|
||||
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
@@ -360,33 +425,37 @@ fun NowPlayingScreen(
|
||||
}
|
||||
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
NpTab.LYRICS -> if (song != null) {
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val lyricsScrollState = rememberScrollState()
|
||||
val lyricsListState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Pre-compute pixel constants for absolute scroll target calculation
|
||||
val lineHeightPx = remember(density) {
|
||||
with(density) { 28.sp.toPx() + 12.dp.toPx() } // lineHeight + 2×vertical padding
|
||||
}
|
||||
val lyricsPaddingPx = remember(density) { with(density) { 200.dp.toPx() } }
|
||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
|
||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||
val anchorFrac = 0.40f
|
||||
|
||||
// 200ms lookahead — same as web app for better sync feel
|
||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
|
||||
else -1
|
||||
|
||||
// Absolute scroll: always lands on the exact pixel, no accumulation lag
|
||||
LaunchedEffect(activeIdx) {
|
||||
// Center the active line using its *measured* position — no cumulative drift, so
|
||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||
LaunchedEffect(activeIdx, lyricsViewH) {
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
// Center of active line sits at 30% from top (clearly upper area)
|
||||
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
|
||||
- lyricsViewH * 0.30f).coerceAtLeast(0f)
|
||||
lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
|
||||
val info = lyricsListState.layoutInfo
|
||||
val anchor = info.viewportStartOffset +
|
||||
((info.viewportEndOffset - info.viewportStartOffset) * anchorFrac).toInt()
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == activeIdx }
|
||||
if (item != null) {
|
||||
val delta = (item.offset + item.size / 2 - anchor).toFloat()
|
||||
lyricsListState.animateScrollBy(
|
||||
delta,
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
|
||||
)
|
||||
} else {
|
||||
// Big jump (instrumental gap → chorus): approximate-anchor now, refine next tick
|
||||
lyricsListState.animateScrollToItem(activeIdx, -anchor)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
@@ -409,17 +478,20 @@ fun NowPlayingScreen(
|
||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
lyricsState.lines.isNotEmpty() -> Column(
|
||||
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||
state = lyricsListState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { lyricsViewH = it.height }
|
||||
.verticalScroll(lyricsScrollState),
|
||||
.onSizeChanged { lyricsViewH = it.height },
|
||||
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
||||
contentPadding = if (lyricsViewH > 0) PaddingValues(
|
||||
top = with(density) { (lyricsViewH * anchorFrac).toDp() },
|
||||
bottom = with(density) { (lyricsViewH * (1f - anchorFrac)).toDp() },
|
||||
) else PaddingValues(vertical = 220.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(200.dp))
|
||||
lyricsState.lines.forEachIndexed { idx, line ->
|
||||
key(idx) {
|
||||
itemsIndexed(lyricsState.lines, key = { idx, _ -> idx }) { idx, line ->
|
||||
LyricsLineItem(
|
||||
text = line.text,
|
||||
isActive = idx == activeIdx,
|
||||
@@ -428,8 +500,6 @@ fun NowPlayingScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(200.dp))
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
@@ -449,7 +519,7 @@ fun NowPlayingScreen(
|
||||
}
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
if (tab == NpTab.QUEUE) {
|
||||
NpTab.QUEUE -> {
|
||||
// Radio mode toggle
|
||||
if (onToggleRadio != null) {
|
||||
Row(
|
||||
@@ -547,6 +617,9 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end when
|
||||
} // end inner Column
|
||||
} // end HorizontalPager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.RemoveCircle
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
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.Song
|
||||
import com.novasonic.app.offline.toSong
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
||||
|
||||
@Composable
|
||||
fun OfflineDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: OfflineDetailViewModel = viewModel()
|
||||
val group by vm.group.collectAsStateWithLifecycle()
|
||||
val tracks by vm.tracks.collectAsStateWithLifecycle()
|
||||
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
// The group flow starts as null before Room emits — don't treat that initial null as "deleted".
|
||||
var everLoaded by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(group) { if (group != null) everLoaded = true }
|
||||
// Only leave once the group was loaded and has since disappeared (last song removed).
|
||||
LaunchedEffect(everLoaded, group) { if (everLoaded && group == null) onBack() }
|
||||
|
||||
val g = group ?: run {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
val songs = remember(tracks) { tracks.map { it.toSong() } }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(g.coverArt),
|
||||
contentDescription = g.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
g.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Offline · ${tracks.size} Titel",
|
||||
fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onPlay(songs, index) },
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
||||
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -27,7 +30,9 @@ import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||
|
||||
private fun normTrack(s: String?) =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
|
||||
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
|
||||
// every downloaded JP track "match" every missing one in the partial-album split).
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
|
||||
|
||||
@Composable
|
||||
fun PartialAlbumScreen(
|
||||
@@ -39,6 +44,7 @@ fun PartialAlbumScreen(
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val ia = partialAlbum.itunesAlbum
|
||||
val nd = partialAlbum.ndAlbum
|
||||
@@ -94,6 +100,50 @@ fun PartialAlbumScreen(
|
||||
.sortedBy { it.trackNumber ?: 999 }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
|
||||
// every track is downloaded yet) ─────────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent)
|
||||
.clickable { onPlay(downloadedSongs, 0) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsBackground)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPlay(downloadedSongs.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 ─────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
@@ -111,6 +161,7 @@ fun PartialAlbumScreen(
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(downloadedSongs, idx) },
|
||||
onStarToggle = {},
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -7,10 +10,11 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -20,7 +24,9 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -28,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
@@ -40,10 +47,27 @@ fun PlaylistDetailScreen(
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onDeleted: () -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: PlaylistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
|
||||
vm.uploadCover(bytes, mimeType)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() }
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -108,6 +132,37 @@ fun PlaylistDetailScreen(
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// Edit + Delete buttons (top-right)
|
||||
Row(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(end = 14.dp, top = 14.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showRenameDialog = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Edit, "Bearbeiten",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showDeleteDialog = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Delete, "Löschen",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
@@ -141,7 +196,10 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
// ── Play / Shuffle / Edit / Download ──────────────────────────────
|
||||
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
|
||||
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
|
||||
// primary action; the rest are outlined and tint accent when active/done.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -151,27 +209,61 @@ fun PlaylistDetailScreen(
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Toggle edit mode for removing songs
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit,
|
||||
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||
OutlinedButton(
|
||||
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
|
||||
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
@@ -179,9 +271,119 @@ fun PlaylistDetailScreen(
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(
|
||||
onClick = { vm.removeSong(idx) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.RemoveCircleOutline,
|
||||
"Entfernen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
|
||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||
if (showRenameDialog) {
|
||||
var nameInput by remember { mutableStateOf(pl.name) }
|
||||
var commentInput by remember { mutableStateOf(pl.comment ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRenameDialog = false },
|
||||
title = { Text("Playlist bearbeiten", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = nameInput,
|
||||
onValueChange = { nameInput = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commentInput,
|
||||
onValueChange = { commentInput = it },
|
||||
label = { Text("Beschreibung") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showRenameDialog = false; imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.AddAPhoto, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Cover hochladen", color = NsAccent, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { vm.rename(nameInput, commentInput); showRenameDialog = false },
|
||||
enabled = nameInput.isNotBlank(),
|
||||
) {
|
||||
Text("Speichern", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRenameDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete confirmation dialog ────────────────────────────────────────────
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Playlist löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
"\"${pl.name}\" wird dauerhaft gelöscht.",
|
||||
fontFamily = Outfit, color = NsOnSurface2,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteDialog = false; vm.delete() },
|
||||
enabled = !state.isSaving,
|
||||
) {
|
||||
Text("Löschen", color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -57,12 +57,12 @@ fun SearchScreen(
|
||||
if (r.song.isNotEmpty()) {
|
||||
item { ResultHeader("Songs") }
|
||||
items(r.song, key = { it.id }) { song ->
|
||||
val idx = r.song.indexOf(song)
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(r.song, idx) },
|
||||
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||
onPlay = { onSongPlay(listOf(song), 0) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -18,6 +18,7 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ConnStatus
|
||||
import com.novasonic.app.viewmodel.SettingsViewModel
|
||||
|
||||
private val SCROBBLE_OPTIONS = listOf(
|
||||
@@ -39,11 +41,12 @@ private val SCROBBLE_OPTIONS = listOf(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||
val vm: SettingsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var showAbsKey by remember { mutableStateOf(false) }
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var scrobbleExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -58,9 +61,7 @@ fun SettingsScreen() {
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit)
|
||||
}
|
||||
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen", fontFamily = Outfit) }
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
@@ -79,24 +80,152 @@ fun SettingsScreen() {
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||
OutlinedButton(
|
||||
onClick = onOpenStats,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onOpenCompare,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
SectionHeader("🎨 Theme")
|
||||
ThemePicker(currentThemeId = state.theme, onSelect = { id -> vm.onTheme(id); applyNsTheme(id) })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||
SectionHeader("📊 Bibliothek")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Zeigt Wiedergabezähler in Song-Reihen an", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showPlayCount, onCheckedChange = vm::onShowPlayCount,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||
onValueChange = {}, readOnly = true,
|
||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = scrobbleExpanded, onDismissRequest = { scrobbleExpanded = false }, containerColor = NsSurface2) {
|
||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||
colors = MenuDefaults.itemColors(textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📚 Audiobookshelf ────────────────────────────────────────────────
|
||||
SectionHeader("📚 Audiobookshelf")
|
||||
|
||||
NsTextField(
|
||||
value = state.absUrl, onValueChange = vm::onAbsUrl,
|
||||
label = "Server URL", placeholder = "https://audiobooks.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.absApiKey, onValueChange = vm::onAbsApiKey,
|
||||
label = "API Key",
|
||||
hint = "Einstellungen → Profil → API Token in Audiobookshelf",
|
||||
visualTransformation = if (showAbsKey) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showAbsKey = !showAbsKey }) {
|
||||
Icon(
|
||||
if (showAbsKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null, tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Kapitel-Fortschrittsanzeige", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Fortschrittsbalken zeigt aktuelles Kapitel statt Gesamtlänge", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.absChapterTrackMode, onCheckedChange = vm::onAbsChapterTrackMode,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🔑 Discover APIs ─────────────────────────────────────────────────
|
||||
SectionHeader("🔑 Discover APIs")
|
||||
|
||||
NsTextField(
|
||||
value = state.lastFmKey, onValueChange = vm::onLastFmKey,
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
hint = "Für Download-to-Server via yt-dlp",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
||||
|
||||
NsTextField(
|
||||
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
|
||||
label = "NovaSonic Scrobble URL",
|
||||
hint = "Play-Log für Statistiken (Port 3710)",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
SectionHeader("🌐 Navidrome Server")
|
||||
|
||||
NsTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = vm::onServerUrl,
|
||||
label = "Server URL",
|
||||
placeholder = "https://musik.beispiel.de",
|
||||
value = state.serverUrl, onValueChange = vm::onServerUrl,
|
||||
label = "Server URL", placeholder = "https://musik.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.username,
|
||||
onValueChange = vm::onUsername,
|
||||
value = state.username, onValueChange = vm::onUsername,
|
||||
label = "Benutzername",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.password,
|
||||
onValueChange = vm::onPassword,
|
||||
value = state.password, onValueChange = vm::onPassword,
|
||||
label = "Passwort",
|
||||
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
@@ -111,124 +240,8 @@ fun SettingsScreen() {
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||
SectionHeader("📊 Bibliothek")
|
||||
|
||||
// Scrobble-Mode dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = scrobbleExpanded,
|
||||
onExpandedChange = { scrobbleExpanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
|
||||
?: "30 Sekunden",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = scrobbleExpanded,
|
||||
onDismissRequest = { scrobbleExpanded = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show play count toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Zeigt Wiedergabezähler in Song-Reihen an",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showPlayCount,
|
||||
onCheckedChange = vm::onShowPlayCount,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground,
|
||||
checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
SectionHeader("🎨 Theme")
|
||||
ThemePicker(
|
||||
currentThemeId = state.theme,
|
||||
onSelect = { id ->
|
||||
vm.onTheme(id)
|
||||
applyNsTheme(id)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🔑 APIs ──────────────────────────────────────────────────────────
|
||||
SectionHeader("🔑 APIs")
|
||||
|
||||
NsTextField(
|
||||
value = state.lastFmKey,
|
||||
onValueChange = vm::onLastFmKey,
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.backendUrl,
|
||||
onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
hint = "Für Download-to-Server via yt-dlp",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Status + Save
|
||||
if (state.error != null) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
if (state.savedOk) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = vm::save,
|
||||
enabled = !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||
|
||||
OutlinedButton(
|
||||
@@ -238,14 +251,75 @@ fun SettingsScreen() {
|
||||
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
||||
),
|
||||
) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButton(vm: SettingsViewModel, isSaving: Boolean, savedOk: Boolean, error: String?) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (error != null) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
if (savedOk) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = vm::save,
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnStatusRow(status: ConnStatus, label: String, onTest: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
when (status) {
|
||||
ConnStatus.Idle -> Spacer(Modifier.size(10.dp))
|
||||
ConnStatus.Checking -> CircularProgressIndicator(modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, color = NsAccent)
|
||||
is ConnStatus.Ok -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
|
||||
is ConnStatus.Err -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935)))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = when (status) {
|
||||
ConnStatus.Idle -> label
|
||||
ConnStatus.Checking -> "Verbinde…"
|
||||
is ConnStatus.Ok -> status.text
|
||||
is ConnStatus.Err -> status.text
|
||||
},
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = when (status) {
|
||||
is ConnStatus.Ok -> Color(0xFF4CAF50)
|
||||
is ConnStatus.Err -> Color(0xFFE53935)
|
||||
else -> NsOnSurface2
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onTest, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text("Testen", fontFamily = Outfit, fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
@@ -271,8 +345,7 @@ private fun NsTextField(
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
value = value, onValueChange = onValueChange,
|
||||
label = { Text(label, fontFamily = Outfit) },
|
||||
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
||||
singleLine = true,
|
||||
@@ -308,29 +381,15 @@ private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
||||
.clickable { onSelect(theme.id) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
// Accent dot
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(theme.accent),
|
||||
)
|
||||
Box(Modifier.size(22.dp).clip(CircleShape).background(theme.accent))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = theme.name,
|
||||
fontFamily = Outfit,
|
||||
text = theme.name, fontFamily = Outfit,
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||
fontSize = 14.sp, color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isActive) {
|
||||
Icon(
|
||||
Icons.Default.Check, null,
|
||||
tint = theme.accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
if (isActive) Icon(Icons.Default.Check, null, tint = theme.accent, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,11 +397,7 @@ private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2,
|
||||
focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
|
||||
)
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.StatsView
|
||||
import com.novasonic.app.viewmodel.StatsViewModel
|
||||
|
||||
private val MONTHS = listOf(
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: StatsViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Meine Statistiken",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Navigator: scope chips + year/month stepper ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
|
||||
val active = v == state.view
|
||||
Text(
|
||||
l,
|
||||
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (active) Color.White else NsOnSurface2,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) NsAccent else Color.Transparent)
|
||||
.clickable { vm.setView(v) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
if (state.view != StatsView.ALL) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||
Text(
|
||||
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
!state.configured -> CenterMessage(
|
||||
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||
)
|
||||
state.error != null -> CenterMessage(state.error!!, NsError)
|
||||
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||
)
|
||||
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsContent(
|
||||
d: ScrobbleStats,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
// Tiles
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
|
||||
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
|
||||
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
|
||||
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (d.series.isNotEmpty()) {
|
||||
SectionHeader("Verlauf")
|
||||
SeriesChart(d.series)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topArtists.isNotEmpty()) {
|
||||
SectionHeader("Top-Künstler")
|
||||
d.topArtists.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topTracks.isNotEmpty()) {
|
||||
SectionHeader("Top-Songs")
|
||||
d.topTracks.forEachIndexed { i, t ->
|
||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topAlbums.isNotEmpty()) {
|
||||
SectionHeader("Top-Alben")
|
||||
d.topAlbums.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topGenres.isNotEmpty()) {
|
||||
SectionHeader("Top-Genres")
|
||||
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.byHour.any { it.plays > 0 }) {
|
||||
SectionHeader("Hör-Uhr (Stunde)")
|
||||
HourClock(d.byHour.map { it.plays })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRow(rank: Int, name: String, plays: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Text(
|
||||
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
|
||||
)
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
glyph,
|
||||
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
|
||||
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(t: String) {
|
||||
Text(
|
||||
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopRow(
|
||||
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
|
||||
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
series.forEach { s ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(20.dp)
|
||||
.height((6 + (84 * s.plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||
.background(NsAccent),
|
||||
)
|
||||
Text(
|
||||
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HourClock(hours: List<Int>) {
|
||||
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
) {
|
||||
hours.forEachIndexed { h, plays ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height((2 + (48 * plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
|
||||
.background(if (plays > 0) NsAccent else NsSurface2),
|
||||
)
|
||||
Text(
|
||||
if (h % 6 == 0) h.toString() else "",
|
||||
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
// "3h 12m" / "12m" / "45s"
|
||||
private fun fmtTime(sec: Long): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
return when {
|
||||
h > 0 -> "${h}h ${m}m"
|
||||
m > 0 -> "${m}m"
|
||||
else -> "${sec}s"
|
||||
}
|
||||
}
|
||||
|
||||
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
|
||||
private fun seriesLabel(b: String): String = when {
|
||||
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
|
||||
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
|
||||
else -> b
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||
object NsColors {
|
||||
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
|
||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||
var bg by mutableStateOf(Color(0xFF070D14))
|
||||
@@ -88,6 +89,7 @@ val NS_THEMES = listOf(
|
||||
|
||||
fun applyNsTheme(themeId: String) {
|
||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||
NsColors.themeId = t.id
|
||||
NsColors.accent = t.accent
|
||||
NsColors.accentDim = t.accentDim
|
||||
NsColors.bg = t.bg
|
||||
|
||||
@@ -6,8 +6,10 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AlbumUiState(
|
||||
@@ -19,11 +21,21 @@ data class AlbumUiState(
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
private val _state = MutableStateFlow(AlbumUiState())
|
||||
val state: StateFlow<AlbumUiState> = _state
|
||||
|
||||
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == "album:$albumId" } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** True while any track in this album's group is queued or actively downloading. */
|
||||
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("album:$albumId")
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init {
|
||||
val id = albumId
|
||||
if (id.isNullOrBlank()) {
|
||||
@@ -39,4 +51,19 @@ class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidVi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAlbum() {
|
||||
val album = _state.value.album ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.ALBUM,
|
||||
sourceId = album.id,
|
||||
name = album.name,
|
||||
coverArt = album.coverArt,
|
||||
songs = album.song,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AlbumViewModel", "enqueueGroup failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -9,6 +8,7 @@ import com.novasonic.app.api.EventsClient
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.api.model.ArtistInfo
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
@@ -41,32 +41,45 @@ data class ArtistUiState(
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Concerts
|
||||
val events: List<ConcertEvent> = emptyList(),
|
||||
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
|
||||
val showEventsSheet: Boolean = false,
|
||||
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||
val fanCount: Long? = null,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
|
||||
// and the picker state (link/id paste + Deezer search candidates).
|
||||
val pin: ArtistOverride? = null,
|
||||
val pinnedImage: String? = null,
|
||||
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
|
||||
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
|
||||
val pinnedRelated: List<ItunesItem> = emptyList(),
|
||||
val showPinPicker: Boolean = false,
|
||||
val pinSearching: Boolean = false,
|
||||
val pinCandidates: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
|
||||
viewModelScope.launch {
|
||||
player.state.collect { ps ->
|
||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,19 +96,30 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
val info = infoDef.await()
|
||||
|
||||
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(
|
||||
artist = artist, info = info, topSongs = top,
|
||||
isLoading = false, itunesLoading = true,
|
||||
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
// Fetch iTunes + events in parallel without blocking the main screen
|
||||
launch { loadItunesData(artist.name) }
|
||||
launch { loadItunesData(artist.name, pin) }
|
||||
launch {
|
||||
val backendUrl = repo.getBackendUrl()
|
||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(events = ev)
|
||||
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
|
||||
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
|
||||
.onFailure { e ->
|
||||
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
|
||||
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||
@@ -103,20 +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 {
|
||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||
}.getOrNull()
|
||||
|
||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
val iArtistId = (
|
||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
||||
?: artists.find {
|
||||
val n = it.artistName ?: return@find false
|
||||
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
|
||||
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
|
||||
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val q = norm(artistName)
|
||||
val iArtist = (
|
||||
artists.firstOrNull { norm(it.artistName) == q }
|
||||
?: artists.firstOrNull {
|
||||
val n = it.artistName ?: return@firstOrNull false
|
||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||
}
|
||||
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
|
||||
)?.artistId
|
||||
?: artists.firstOrNull()
|
||||
)
|
||||
val iArtistId = iArtist?.artistId
|
||||
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
@@ -128,11 +181,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
itunesLoading = false,
|
||||
)
|
||||
}
|
||||
enrichPartialCounts()
|
||||
} else {
|
||||
_state.value = _state.value.copy(itunesLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
|
||||
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
|
||||
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
|
||||
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
|
||||
* a couple of calls, not the whole discography.
|
||||
*/
|
||||
private suspend fun enrichPartialCounts() {
|
||||
val ndAlbums = _state.value.artist?.album ?: return
|
||||
val iAlbums = _state.value.iAlbums
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val enriched = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) ia.copy(trackCount = n) else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
_state.value = _state.value.copy(iAlbums = enriched)
|
||||
}
|
||||
|
||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||
|
||||
fun loadSubAlbum(album: ItunesItem) {
|
||||
@@ -254,41 +337,102 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
}
|
||||
|
||||
// ── iTunes 30s preview ───────────────────────────────────────────────────
|
||||
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
player.pause()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||
}
|
||||
|
||||
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 stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
|
||||
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
|
||||
val id = parseDeezerArtistId(raw)?.toLongOrNull()
|
||||
if (id == null) { onResult(false); return }
|
||||
viewModelScope.launch {
|
||||
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
|
||||
if (a?.artistId == null) { onResult(false); return@launch }
|
||||
applyPin(ArtistOverride(
|
||||
deezerArtistId = a.artistId.toString(),
|
||||
name = a.artistName ?: _state.value.artist?.name ?: "",
|
||||
image = a.artworkUrl100 ?: "",
|
||||
fanCount = a.fanCount,
|
||||
))
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
|
||||
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
|
||||
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
|
||||
*/
|
||||
suspend fun libraryArtistId(name: String?): String? {
|
||||
val n = name?.trim()?.lowercase() ?: return null
|
||||
if (n.isEmpty()) return null
|
||||
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
|
||||
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
|
||||
}
|
||||
|
||||
fun clearPin() {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.removeArtistOverride(id) }
|
||||
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
|
||||
loadItunesData(_state.value.artist?.name ?: "", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPin(ov: ArtistOverride) {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.setArtistOverride(id, ov) }
|
||||
_state.value = _state.value.copy(
|
||||
pin = ov,
|
||||
pinnedImage = ov.image.takeIf { it.isNotBlank() },
|
||||
showPinPicker = false,
|
||||
itunesLoading = true,
|
||||
)
|
||||
loadItunesData(_state.value.artist?.name ?: "", ov)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
|
||||
private fun parseDeezerArtistId(raw: String): String? {
|
||||
val s = raw.trim()
|
||||
if (s.isEmpty()) return null
|
||||
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
|
||||
return if (s.matches(Regex("\\d+"))) s else null
|
||||
}
|
||||
|
||||
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||
// global bar) after leaving this screen.
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.CompareResult
|
||||
import com.novasonic.app.api.model.ScrobbleUser
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CompareUiState(
|
||||
val loading: Boolean = true,
|
||||
val configured: Boolean = true,
|
||||
val error: String? = null,
|
||||
val me: String = "",
|
||||
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
|
||||
val partner: String? = null, // chosen userB; null → show picker
|
||||
val result: CompareResult? = null,
|
||||
)
|
||||
|
||||
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
|
||||
class CompareViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(CompareUiState())
|
||||
val state: StateFlow<CompareUiState> = _state
|
||||
|
||||
init { loadUsers() }
|
||||
|
||||
fun loadUsers() {
|
||||
viewModelScope.launch {
|
||||
_state.value = CompareUiState(loading = true)
|
||||
val settings = repo.appSettingsFlow().first()
|
||||
if (settings.scrobbleUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(loading = false, configured = false)
|
||||
return@launch
|
||||
}
|
||||
val resp = repo.fetchScrobbleUsers()
|
||||
_state.value = if (resp == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, error = null,
|
||||
me = resp.me,
|
||||
users = resp.users.filter { it.user != resp.me },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to the partner picker. */
|
||||
fun clearPartner() {
|
||||
_state.value = _state.value.copy(partner = null, result = null, error = null)
|
||||
}
|
||||
|
||||
fun choosePartner(name: String) {
|
||||
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
|
||||
viewModelScope.launch {
|
||||
val res = repo.fetchScrobbleCompare(name)
|
||||
_state.value = if (res == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(loading = false, result = res, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
@@ -11,8 +10,10 @@ import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.ItunesRssEntry
|
||||
import com.novasonic.app.api.model.Song
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import com.novasonic.app.api.model.artworkHd
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -50,6 +51,9 @@ data class ExploreUiState(
|
||||
val albumItem: ItunesItem? = null,
|
||||
// Artist detail (iTunes)
|
||||
val artistDetail: ItunesArtistState? = null,
|
||||
// Which "all albums" section is expanded inside the iTunes artist overlay (by title), or null.
|
||||
// Hoisted here so it survives drilling into an album (nav route OR overlay) and coming back.
|
||||
val artistAllAlbums: String? = null,
|
||||
// Partial album detail (opened from artist's "Unvollständig" section)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Download sheet
|
||||
@@ -68,18 +72,22 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
|
||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
|
||||
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
|
||||
val searchListState = LazyListState()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init {
|
||||
loadCharts()
|
||||
// Mirror the app-level preview state so the track rows still show which clip is playing.
|
||||
viewModelScope.launch {
|
||||
player.state.collect { ps ->
|
||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,23 +110,54 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
private fun search(q: String) {
|
||||
// Strip leading aesthetic punctuation (".diedlonely", "†…") so iTunes actually surfaces the
|
||||
// artist instead of nothing. Falls back to the raw query if it was only symbols.
|
||||
val term = q.trimStart { !it.isLetterOrDigit() }.trim().ifBlank { q }
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, error = null)
|
||||
try {
|
||||
val resp = ItunesClient.search(q)
|
||||
val resp = ItunesClient.search(term)
|
||||
val results = resp.results
|
||||
val artists = results.filter { it.wrapperType == "artist" }
|
||||
_state.value = _state.value.copy(
|
||||
artists = results.filter { it.wrapperType == "artist" },
|
||||
artists = artists,
|
||||
albums = results.filter { it.wrapperType == "collection" },
|
||||
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
||||
isSearching = false,
|
||||
)
|
||||
enrichArtistArt(artists)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iTunes artist entities carry no artwork, so search results render as grey circles. Mirror the
|
||||
* web PWA: fetch one album per artist and borrow its cover. Runs after the main results are shown
|
||||
* (list appears instantly) and only applies if the user is still viewing the same artist set.
|
||||
*/
|
||||
private fun enrichArtistArt(artists: List<ItunesItem>) {
|
||||
if (artists.none { it.artworkUrl100.isNullOrBlank() && it.artistName != null }) return
|
||||
viewModelScope.launch {
|
||||
val enriched = artists.map { a ->
|
||||
async {
|
||||
if (!a.artworkUrl100.isNullOrBlank()) return@async a
|
||||
val name = a.artistName ?: return@async a
|
||||
val art = runCatching {
|
||||
ItunesClient.search(name, entity = "album", limit = 1).results
|
||||
.firstOrNull { it.wrapperType == "collection" }?.artworkUrl100
|
||||
}.getOrNull()
|
||||
if (art != null) a.copy(artworkUrl100 = art) else a
|
||||
}
|
||||
}.awaitAll()
|
||||
val cur = _state.value.artists
|
||||
if (cur.map { it.artistId } == artists.map { it.artistId }) {
|
||||
_state.value = _state.value.copy(artists = enriched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -137,9 +176,13 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||
}
|
||||
|
||||
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
|
||||
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
|
||||
|
||||
fun openArtistDetail(artist: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
||||
artistAllAlbums = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val artistId = artist.artistId
|
||||
@@ -150,21 +193,58 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
val ndAlbums = ndAlbumsD.await()
|
||||
val iAlbums = iAlbumsD.await()
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = artist,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbumsD.await(),
|
||||
iAlbums = iAlbumsD.await(),
|
||||
ndAlbums = ndAlbums,
|
||||
iAlbums = iAlbums,
|
||||
iTracks = iTracksD.await(),
|
||||
topSongs = topSongsD.await(),
|
||||
),
|
||||
)
|
||||
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
|
||||
// Resolve the real total for matched albums (like web) so partials are detected.
|
||||
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
|
||||
if (enriched !== iAlbums) {
|
||||
_state.value.artistDetail?.let { d ->
|
||||
if (d.artist.artistId == artist.artistId) {
|
||||
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
|
||||
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
|
||||
private suspend fun enrichPartialCounts(
|
||||
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
|
||||
): List<ItunesItem> {
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
var changed = false
|
||||
val out = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
return if (changed) out else iAlbums
|
||||
}
|
||||
|
||||
fun closeArtistDetail() {
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||
}
|
||||
|
||||
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
||||
@@ -240,7 +320,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onSuccess {
|
||||
// App-lifetime poll → success/failure notification even after leaving the screen
|
||||
// (previously only the artist page + importer notified; search/Bibliothek was silent).
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
@@ -262,41 +350,12 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
val cur = _state.value.previewingId
|
||||
if (cur == id) {
|
||||
stopPreview()
|
||||
} else {
|
||||
startPreview(url, id)
|
||||
}
|
||||
player.pause() // don't overlap the 30s clip with library playback
|
||||
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
player.pause()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
|
||||
// tab keeps it playing with the global control bar (see AppNavigation).
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroAlbum: Album? = null,
|
||||
val heroLabel: String = "",
|
||||
val recentAlbums: List<Album> = emptyList(),
|
||||
val recentlyPlayedAlbums: List<Album> = emptyList(),
|
||||
val frequentAlbums: List<Album> = emptyList(),
|
||||
val randomAlbums: List<Album> = emptyList(),
|
||||
val genres: List<Genre> = emptyList(),
|
||||
@@ -42,23 +44,42 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val recentDef = async { repo.getRecentAlbums(20) }
|
||||
val frequentDef = async { repo.getFrequentAlbums(20) }
|
||||
val randomDef = async { repo.getRandomAlbums(20) }
|
||||
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
|
||||
val recentDef = async { runCatching { repo.getRecentAlbums(20) } }
|
||||
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } }
|
||||
val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } }
|
||||
val randomDef = async { runCatching { repo.getRandomAlbums(20) } }
|
||||
val genresDef = async { runCatching { repo.getGenres() } }
|
||||
|
||||
val recent = recentDef.await()
|
||||
val frequent = frequentDef.await()
|
||||
val random = randomDef.await()
|
||||
val genres = genresDef.await()
|
||||
val recentResult = recentDef.await()
|
||||
val recentlyPlayedResult = recentlyPlayedDef.await()
|
||||
val frequentResult = frequentDef.await()
|
||||
val randomResult = randomDef.await()
|
||||
val genresResult = genresDef.await()
|
||||
|
||||
// Hero rotates between first recent and first frequent album
|
||||
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
|
||||
else frequent.firstOrNull() ?: recent.firstOrNull()
|
||||
if (recentResult.isFailure) {
|
||||
_state.value = HomeUiState(error = recentResult.exceptionOrNull()?.message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val recent = recentResult.getOrDefault(emptyList())
|
||||
val recentlyPlayed = recentlyPlayedResult.getOrDefault(emptyList())
|
||||
val frequent = frequentResult.getOrDefault(emptyList())
|
||||
val random = randomResult.getOrDefault(emptyList())
|
||||
val genres = genresResult.getOrDefault(emptyList())
|
||||
|
||||
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
|
||||
val frequentIds = frequent.take(5).map { it.id }.toSet()
|
||||
val seen = mutableSetOf<String>()
|
||||
val pool = (frequent.take(5) + recentlyPlayed.take(5))
|
||||
.filter { it.id.isNotBlank() && seen.add(it.id) }
|
||||
val hero = pool.randomOrNull()
|
||||
val heroLabel = if (hero != null && frequentIds.contains(hero.id)) "MEISTGESPIELT" else "ZULETZT GEHÖRT"
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
heroAlbum = hero,
|
||||
heroLabel = heroLabel,
|
||||
recentAlbums = recent,
|
||||
recentlyPlayedAlbums = recentlyPlayed,
|
||||
frequentAlbums = frequent,
|
||||
randomAlbums = random,
|
||||
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
|
||||
|
||||
@@ -6,8 +6,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ImportResolveResponse
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.AppNotification
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,11 +27,15 @@ data class ImporterUiState(
|
||||
val jobMessage: String? = null,
|
||||
val jobCompleted: Int = 0,
|
||||
val jobTotal: Int = 0,
|
||||
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||
val previewingId: Long? = null,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
|
||||
private val _state = MutableStateFlow(ImporterUiState())
|
||||
val state: StateFlow<ImporterUiState> = _state
|
||||
@@ -42,7 +46,28 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||
}
|
||||
}
|
||||
// Mirror the app-level preview so the track rows show which clip is playing.
|
||||
viewModelScope.launch {
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
|
||||
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
|
||||
|
||||
fun togglePreview(track: ImportTrack) {
|
||||
val url = track.previewUrl ?: return
|
||||
player.pause() // don't overlap the 30s clip with library playback
|
||||
preview.toggle(
|
||||
previewId(track), url,
|
||||
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
|
||||
)
|
||||
}
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
@@ -110,6 +135,17 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
tracks = resolved.tracks,
|
||||
)
|
||||
)
|
||||
// App-lifetime poll → notification delivered even if user navigates away
|
||||
NotificationStore.startPoll(
|
||||
backendUrl = s.backendUrl,
|
||||
jobId = jobId,
|
||||
title = resolved.name ?: "Import",
|
||||
artist = resolved.artist ?: "",
|
||||
navType = resolved.type ?: "",
|
||||
navAlbum = resolved.name ?: "",
|
||||
navArtist = resolved.artist ?: "",
|
||||
)
|
||||
// In-screen progress poll (cancelled on navigation — only for UI)
|
||||
pollJob(client, jobId)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
@@ -137,13 +173,6 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
NotificationStore.add(AppNotification(
|
||||
id = jobId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
status = st.status ?: "error",
|
||||
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ArtistIndex
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
|
||||
enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS }
|
||||
enum class AlbumSort { NEWEST, OLDEST, AZ }
|
||||
|
||||
data class LibraryUiState(
|
||||
val tab: LibraryTab = LibraryTab.PLAYLISTS,
|
||||
val albums: List<Album> = emptyList(),
|
||||
// Two pre-fetched lists — avoids re-fetch when switching sort
|
||||
val albumsByNewest: List<Album> = emptyList(), // type=newest (Navidrome add-date)
|
||||
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
@@ -29,22 +36,44 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||
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() }
|
||||
|
||||
fun setAlbumSort(sort: AlbumSort) {
|
||||
_state.value = _state.value.copy(albumSort = sort)
|
||||
}
|
||||
|
||||
fun selectTab(tab: LibraryTab) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
when (tab) {
|
||||
LibraryTab.ALBUMS -> if (_state.value.albums.isEmpty()) loadAlbums()
|
||||
LibraryTab.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums()
|
||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||
LibraryTab.OFFLINE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAlbums() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val albums = repo.getAlphaAlbums(200)
|
||||
_state.value = _state.value.copy(albums = albums, isLoading = false)
|
||||
val newestDef = async { runCatching { repo.getRecentAlbums(500) } }
|
||||
val azDef = async { runCatching { repo.getAlphaAlbums(500) } }
|
||||
val newestResult = newestDef.await()
|
||||
val azResult = azDef.await()
|
||||
if (newestResult.isFailure) {
|
||||
_state.value = _state.value.copy(error = newestResult.exceptionOrNull()?.message, isLoading = false)
|
||||
return@launch
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
albumsByNewest = newestResult.getOrDefault(emptyList()),
|
||||
albumsByName = azResult.getOrDefault(emptyList()),
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
@@ -69,4 +98,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val newPl = repo.createPlaylist(trimmed, emptyList())
|
||||
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
|
||||
_state.value = _state.value.copy(playlists = pls)
|
||||
onSuccess(newPl.id)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadPlaylists() = loadPlaylists()
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.LrcLine
|
||||
import com.novasonic.app.api.parseLrc
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,12 +16,11 @@ data class LyricsUiState(
|
||||
val plainText: String = "", // fallback plain lyrics
|
||||
val isLoading: Boolean = false,
|
||||
val notFound: Boolean = false,
|
||||
val songId: String = "", // which song these are for
|
||||
val songId: String = "",
|
||||
)
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = MusicRepository(app.applicationContext)
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
val state: StateFlow<LyricsUiState> = _state
|
||||
|
||||
@@ -29,30 +28,18 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
if (_state.value.songId == song.id && !_state.value.isLoading) return
|
||||
_state.value = LyricsUiState(isLoading = true, songId = song.id)
|
||||
viewModelScope.launch {
|
||||
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
|
||||
val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
|
||||
val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
|
||||
val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
|
||||
|
||||
when {
|
||||
synced != null -> {
|
||||
val lines = synced.line
|
||||
.mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
|
||||
.filter { it.text.isNotBlank() }
|
||||
.sortedBy { it.timeMs }
|
||||
_state.value = if (lines.isNotEmpty())
|
||||
LyricsUiState(lines = lines, songId = song.id)
|
||||
else
|
||||
LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
plain != null -> {
|
||||
val text = plain.line.joinToString("\n") { it.value }.trim()
|
||||
_state.value = if (text.isNotBlank())
|
||||
LyricsUiState(plainText = text, songId = song.id)
|
||||
else
|
||||
LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
else -> _state.value = LyricsUiState(notFound = true, songId = song.id)
|
||||
val result = LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
_state.value = when {
|
||||
result == null -> LyricsUiState(notFound = true, songId = song.id)
|
||||
result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id)
|
||||
!result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id)
|
||||
!result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id)
|
||||
else -> LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadedTrackEntity
|
||||
import com.novasonic.app.offline.toSong
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OfflineDetailViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
// groupId is passed URL-encoded in the route; Navigation decodes it back to "album:xyz".
|
||||
private val groupId: String = savedState["groupId"] ?: ""
|
||||
|
||||
val group: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == groupId } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val tracks: StateFlow<List<DownloadedTrackEntity>> = offlineRepo.tracksForGroupFlow(groupId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun songs(): List<Song> = tracks.value.map { it.toSong() }
|
||||
|
||||
fun deleteSong(songId: String) {
|
||||
viewModelScope.launch { offlineRepo.removeSongFromGroup(groupId, songId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.toSong
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OfflineViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
|
||||
val groups: StateFlow<List<DownloadGroupEntity>> = offlineRepo.allGroupsFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val storageBytes: StateFlow<Long> = groups
|
||||
.map { withContext(Dispatchers.IO) { offlineRepo.storageUsedBytes() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
|
||||
|
||||
fun deleteGroup(groupId: String) {
|
||||
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
|
||||
}
|
||||
|
||||
suspend fun songsForGroup(groupId: String): List<Song> =
|
||||
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
|
||||
}
|
||||
@@ -6,30 +6,52 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.PlaylistWithSongs
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlaylistUiState(
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val playlistId: String? = savedState["playlistId"]
|
||||
|
||||
private val _state = MutableStateFlow(PlaylistUiState())
|
||||
val state: StateFlow<PlaylistUiState> = _state
|
||||
|
||||
init {
|
||||
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == "playlist:$playlistId" } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** True while any track in this playlist's group is queued or actively downloading. */
|
||||
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("playlist:$playlistId")
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
val id = playlistId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
|
||||
@@ -38,5 +60,101 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(newName: String, newComment: String? = null) {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
val comment = newComment?.trim() ?: pl.comment
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.updatePlaylistMeta(id, trimmed, comment)
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(name = trimmed, comment = comment),
|
||||
isSaving = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val id = playlistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.deletePlaylist(id)
|
||||
_state.value = _state.value.copy(deleted = true, isSaving = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadCover(imageBytes: ByteArray, mimeType: String) {
|
||||
val id = playlistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.uploadPlaylistCover(id, imageBytes, mimeType)
|
||||
// Reload to get the updated coverArt ID from Navidrome
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = _state.value.copy(playlist = pl, isSaving = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCover() {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.removePlaylistCover(id)
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(coverArt = null),
|
||||
isSaving = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadPlaylist() {
|
||||
val pl = _state.value.playlist ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.PLAYLIST,
|
||||
sourceId = pl.id,
|
||||
name = pl.name,
|
||||
coverArt = pl.coverArt,
|
||||
songs = pl.entry,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("PlaylistViewModel", "downloadPlaylist failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSong(index: Int) {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
if (index < 0 || index >= pl.entry.size) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.removeSongsFromPlaylist(id, listOf(index))
|
||||
val newSongs = pl.entry.toMutableList().also { it.removeAt(index) }
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(entry = newSongs, songCount = newSongs.size)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.SearchResult3
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -16,6 +18,8 @@ data class SearchUiState(
|
||||
val searchResult: SearchResult3? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
|
||||
val overrides: Map<String, ArtistOverride> = emptyMap(),
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
@@ -25,8 +29,18 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state
|
||||
|
||||
// Results scroll — hoisted so it survives a drill-in/back within the search.
|
||||
val listState = LazyListState()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
|
||||
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) = onSearch(q)
|
||||
|
||||
fun onSearch(q: String) {
|
||||
|
||||
@@ -1,33 +1,55 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.theme.NsColors
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class ConnStatus {
|
||||
object Idle : ConnStatus()
|
||||
object Checking : ConnStatus()
|
||||
data class Ok(val text: String) : ConnStatus()
|
||||
data class Err(val text: String) : ConnStatus()
|
||||
}
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val savedOk: Boolean = false,
|
||||
val error: String? = null,
|
||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
|
||||
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
|
||||
val state: StateFlow<SettingsUiState> = _state
|
||||
|
||||
init {
|
||||
@@ -39,9 +61,13 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey,
|
||||
backendUrl = s.backendUrl,
|
||||
scrobbleUrl = s.scrobbleUrl,
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl,
|
||||
absApiKey = s.absApiKey,
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -51,15 +77,84 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun onUsername(v: String) = update { it.copy(username = 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 onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false) }
|
||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
|
||||
|
||||
fun onTheme(id: String) {
|
||||
update { it.copy(theme = id) }
|
||||
viewModelScope.launch { repo.saveTheme(id) }
|
||||
}
|
||||
|
||||
fun testAbs() {
|
||||
val url = _state.value.absUrl.trim().trimEnd('/')
|
||||
val key = _state.value.absApiKey.trim()
|
||||
if (url.isBlank() || key.isBlank()) {
|
||||
update { it.copy(absStatus = ConnStatus.Err("URL und API Key eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(absStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
val backend = _state.value.backendUrl.trim().trimEnd('/')
|
||||
val ok = runCatching { AbsClient(url, key, backend).ping() }.getOrDefault(false)
|
||||
update { it.copy(absStatus = if (ok) ConnStatus.Ok("Verbunden · $url") else ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
|
||||
fun testBackend() {
|
||||
val url = _state.value.backendUrl.trim().trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("Backend URL eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(backendStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||
}
|
||||
val resp = http.get("$url/api/health")
|
||||
http.close()
|
||||
if (resp.status == HttpStatusCode.OK) {
|
||||
update { it.copy(backendStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||
} else {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testScrobble() {
|
||||
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||
}
|
||||
val resp = http.get("$url/scrobble/health")
|
||||
http.close()
|
||||
if (resp.status == HttpStatusCode.OK) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||
} else {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||
@@ -75,9 +170,13 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey.trim(),
|
||||
backendUrl = s.backendUrl.trimEnd('/'),
|
||||
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl.trimEnd('/'),
|
||||
absApiKey = s.absApiKey.trim(),
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
))
|
||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:pivotX="12"
|
||||
android:scaleX="-1">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,5L12,1L7,6L12,11L12,7C15.31,7 18,9.69 18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13L4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13C20,8.58 16.42,5 12,5Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M6,19h4L10,5H6v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M8,5.14v14l11,-7z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,5L12,1L7,6L12,11L12,7C15.31,7 18,9.69 18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13L4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13C20,8.58 16.42,5 12,5Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M6,6 L14.5,12 L6,18 Z M16,6 L18,6 L18,18 L16,18 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M6,6 L8,6 L8,18 L6,18 Z M9.5,12 L18,18 L18,6 Z"/>
|
||||
</vector>
|
||||
@@ -1,13 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#1a1a2e"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
android:fillColor="#07070f"
|
||||
android:pathData="M0,0H108V108H0Z"/>
|
||||
|
||||
<!-- Subtle blue glow -->
|
||||
<path
|
||||
android:fillColor="#e040fb"
|
||||
android:pathData="M54,27v23.24c-1.33-0.78-2.87-1.24-4.5-1.24C44.81,49,41,52.81,41,57.5S44.81,66,49.5,66S58,62.19,58,57.5V36h9V27H54z" />
|
||||
android:fillColor="#0d1540"
|
||||
android:pathData="M54,54m-38,0a38,38,0,1,0,76,0a38,38,0,1,0,-76,0"/>
|
||||
|
||||
<!-- Equalizer bars (5 bars, varying heights, vertically centred) combined into one path -->
|
||||
<path
|
||||
android:pathData="M28,41 A5,5,0,0,1,33,46 V61 A5,5,0,0,1,28,66 A5,5,0,0,1,23,61 V46 A5,5,0,0,1,28,41 Z
|
||||
M41,32 A5,5,0,0,1,46,37 V71 A5,5,0,0,1,41,76 A5,5,0,0,1,36,71 V37 A5,5,0,0,1,41,32 Z
|
||||
M54,24 A5,5,0,0,1,59,29 V80 A5,5,0,0,1,54,85 A5,5,0,0,1,49,80 V29 A5,5,0,0,1,54,24 Z
|
||||
M67,35 A5,5,0,0,1,72,40 V68 A5,5,0,0,1,67,73 A5,5,0,0,1,62,68 V40 A5,5,0,0,1,67,35 Z
|
||||
M80,44 A5,5,0,0,1,85,49 V60 A5,5,0,0,1,80,65 A5,5,0,0,1,75,60 V49 A5,5,0,0,1,80,44 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="54"
|
||||
android:startY="24"
|
||||
android:endX="54"
|
||||
android:endY="85"
|
||||
android:startColor="#7eb8ff"
|
||||
android:endColor="#8b3cf7"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- The downloaded update APK lives in cacheDir/updates and is shared with the system installer. -->
|
||||
<cache-path name="updates" path="updates/"/>
|
||||
</paths>
|
||||
@@ -12,6 +12,7 @@ ktor = "2.3.12"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
room = "2.6.1"
|
||||
workmanager = "2.9.1"
|
||||
datastore = "1.1.1"
|
||||
coil = "2.7.0"
|
||||
splashscreen = "1.0.1"
|
||||
@@ -63,6 +64,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref =
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# WorkManager
|
||||
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
|
||||
|
||||
# DataStore (settings / credentials)
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ const CONFIG = {
|
||||
audioFmt: process.env.AUDIO_FMT || 'mp3',
|
||||
audioQuality: process.env.AUDIO_QUALITY || '0',
|
||||
maxParallel: parseInt(process.env.MAX_PARALLEL || '2', 10),
|
||||
// 'ytdlp' | 'squid' | 'auto' (squid first, yt-dlp fallback)
|
||||
downloadBackend: (process.env.DOWNLOAD_BACKEND || 'auto').toLowerCase(),
|
||||
// squid.wtf quality: 'mp3' | 'flac' | 'hi-res'
|
||||
squidQuality: (process.env.SQUID_QUALITY || 'mp3').toLowerCase(),
|
||||
downloadBackend: (process.env.DOWNLOAD_BACKEND || 'ytdlp').toLowerCase(),
|
||||
ticketmasterKey: process.env.TICKETMASTER_API_KEY || '',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
const https = require('https');
|
||||
|
||||
// ─── DEEZER METADATA SOURCE ───────────────────────────────────────────────────
|
||||
// Apple's iTunes /search returns 403 from server/datacenter IPs, so all server-side
|
||||
// metadata (single-track enrichment, album track lists, artist discography, the
|
||||
// metadata-editor autofill) comes from Deezer. Free, no key, ~50 req/5s, returns
|
||||
// explicit flags + artist images + durations. Results are mapped to the same
|
||||
// "ItunesItem" shape the download pipeline (downloadAlbumTracks / downloadTrack)
|
||||
// already consumes, so nothing downstream had to change.
|
||||
|
||||
const DZ = 'https://api.deezer.com';
|
||||
|
||||
function dzGet(subpath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(`${DZ}/${subpath}`, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d) || {}); } catch { reject(new Error('Deezer parse error')); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── matching helpers (local copies to avoid a circular require with metadata.js) ─
|
||||
function normalizeTitle(s) {
|
||||
return (s || '')
|
||||
.toLowerCase().trim()
|
||||
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
|
||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
||||
.replace(/[,.'!?]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
|
||||
const yearOf = d => { const y = d ? new Date(d).getFullYear() : NaN; return Number.isFinite(y) ? y : undefined; };
|
||||
// Deezer covers come in fixed sizes; cover_xl is 1000x1000 (matches the iTunes hi-res artwork).
|
||||
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || a?.cover || '';
|
||||
|
||||
// Map one album track (from /album/{id} .tracks.data — these carry no track_position,
|
||||
// so the album's own ordering supplies the number) to the iTunes track shape.
|
||||
function mapAlbumTrack(t, al, index) {
|
||||
return {
|
||||
wrapperType: 'track',
|
||||
trackId: t.id,
|
||||
trackName: t.title,
|
||||
artistName: t.artist?.name || al.artist?.name || '', // per-track artist (mixed albums)
|
||||
collectionName: al.title,
|
||||
collectionArtistName: al.artist?.name || '', // album-level artist (stable)
|
||||
collectionType: al.record_type === 'compilation' ? 'Compilation' : 'Album',
|
||||
artworkUrl100: coverXl(al),
|
||||
releaseDate: al.release_date || undefined,
|
||||
primaryGenreName: al.genres?.data?.[0]?.name || undefined,
|
||||
trackNumber: index + 1,
|
||||
trackCount: al.nb_tracks || undefined,
|
||||
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
|
||||
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
|
||||
previewUrl: t.preview || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Pick the best track from a Deezer /search result list for (artist, title). Pure + exported
|
||||
// so the matching rules are unit-testable. Strict = same title AND same first artist word;
|
||||
// falls back to a title-only match, but never for Japanese titles (same title, different artist
|
||||
// is common for anime/OST). Returns the matched Deezer track or null.
|
||||
function pickTrackMatch(results, artist, title) {
|
||||
if (!Array.isArray(results) || !results.length) return null;
|
||||
const normT = normalizeTitle(title);
|
||||
const normA = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()).toLowerCase().trim();
|
||||
const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
|
||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
||||
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
|
||||
// other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES
|
||||
// the old blind title-only fallback, which grabbed a same-titled song by a completely different
|
||||
// artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year.
|
||||
const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
const wantA = _n(normA);
|
||||
const artistOk = candA => {
|
||||
if (!wantA || !candA) return false;
|
||||
if (candA === wantA) return true;
|
||||
const short = Math.min(candA.length, wantA.length);
|
||||
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
|
||||
};
|
||||
const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name)));
|
||||
return strict || (isJp ? null : loose) || null;
|
||||
}
|
||||
|
||||
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
|
||||
// Fills album/year/trackNum/genre/duration/artwork when the caller only has artist+title.
|
||||
// Returns {} when nothing convincing matches (callers treat that as "no enrichment").
|
||||
async function lookupTrackMeta(artist, title) {
|
||||
try {
|
||||
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||
const searchTitle = baseTitle !== title ? baseTitle : title;
|
||||
const searchArtist = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
|
||||
const q = encodeURIComponent(`${searchArtist} ${searchTitle}`);
|
||||
const results = (await dzGet(`search?q=${q}&limit=10`)).data || [];
|
||||
if (!results.length) return {};
|
||||
|
||||
const match = pickTrackMatch(results, artist, title);
|
||||
if (!match) return {};
|
||||
|
||||
// The search result lacks genre / track number / release date — the album endpoint carries them.
|
||||
const al = match.album?.id ? await dzGet(`album/${match.album.id}`).catch(() => ({})) : {};
|
||||
// track_position + disk_number live on the full /track record, not on the search hit.
|
||||
const tr = match.id ? await dzGet(`track/${match.id}`).catch(() => ({})) : {};
|
||||
|
||||
return {
|
||||
album: match.album?.title || al.title || '',
|
||||
albumArtist: al.artist?.name || undefined,
|
||||
trackName: match.title || undefined,
|
||||
year: yearOf(tr.release_date || al.release_date),
|
||||
trackNum: tr.track_position || undefined,
|
||||
totalTracks: al.nb_tracks || undefined,
|
||||
discNum: tr.disk_number || undefined,
|
||||
discTotal: undefined,
|
||||
genre: al.genres?.data?.[0]?.name || undefined,
|
||||
artworkUrl: coverXl(al) || coverXl(match.album) || '',
|
||||
durationMs: (tr.duration || match.duration) ? (tr.duration || match.duration) * 1000 : undefined,
|
||||
metaArtistName: match.artist?.name || undefined,
|
||||
};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// All tracks of a Deezer album id, in the legacy ItunesItem track shape.
|
||||
async function fetchAlbumTracks(albumId) {
|
||||
try {
|
||||
const al = await dzGet(`album/${albumId}`);
|
||||
return (al.tracks?.data || []).map((t, i) => mapAlbumTrack(t, al, i));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Album fallback when no album id is known: search Deezer for the album, then its tracks.
|
||||
async function searchAlbumTracks(artist, album) {
|
||||
try {
|
||||
const q = encodeURIComponent(`${artist} ${album}`);
|
||||
const results = (await dzGet(`search/album?q=${q}&limit=5`)).data || [];
|
||||
const lc = s => (s || '').toLowerCase();
|
||||
const match = results.find(r =>
|
||||
lc(r.title).includes(lc(album)) || lc(r.artist?.name).includes(lc(artist))
|
||||
) || results[0];
|
||||
return match ? await fetchAlbumTracks(match.id) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Artist discography for the artist-download: search the artist, take real albums
|
||||
// (record_type "album", not singles/EPs), return {collectionId, collectionName, artworkUrl100}.
|
||||
async function fetchArtistAlbums(artistName, limit = 10) {
|
||||
try {
|
||||
const artists = (await dzGet(`search/artist?q=${encodeURIComponent(artistName)}&limit=1`)).data || [];
|
||||
if (!artists.length) return [];
|
||||
const albums = (await dzGet(`artist/${artists[0].id}/albums?limit=100`)).data || [];
|
||||
return albums
|
||||
.filter(a => a.record_type === 'album')
|
||||
.slice(0, limit)
|
||||
.map(a => ({
|
||||
collectionId: a.id,
|
||||
collectionName: a.title,
|
||||
artistName: artists[0].name,
|
||||
artworkUrl100: coverXl(a),
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dzGet,
|
||||
lookupTrackMeta,
|
||||
fetchAlbumTracks,
|
||||
searchAlbumTracks,
|
||||
fetchArtistAlbums,
|
||||
// exported for unit tests
|
||||
mapAlbumTrack,
|
||||
pickTrackMatch,
|
||||
normalizeTitle,
|
||||
};
|
||||
+10
-18
@@ -1,24 +1,9 @@
|
||||
const CONFIG = require('./config');
|
||||
const { log, updateJob } = require('./jobs');
|
||||
const ytdlp = require('./ytdlp');
|
||||
const squid = require('./squid');
|
||||
const CONFIG = require('./config');
|
||||
|
||||
// Route a single track download to the correct backend, with optional fallback.
|
||||
// Download a single track via yt-dlp.
|
||||
async function downloadTrack(opts) {
|
||||
const backend = CONFIG.downloadBackend;
|
||||
|
||||
if (backend === 'ytdlp') return ytdlp.downloadTrack(opts);
|
||||
if (backend === 'squid') return squid.downloadTrack(opts);
|
||||
|
||||
// 'auto': squid first, yt-dlp as fallback
|
||||
try {
|
||||
const result = await squid.downloadTrack(opts);
|
||||
if (result !== null) return result;
|
||||
// null = not found on Qobuz — fall through to yt-dlp
|
||||
log(opts.jobId, '⚠️ squid.wtf: nicht gefunden — Fallback zu yt-dlp');
|
||||
} catch (e) {
|
||||
log(opts.jobId, `⚠️ squid.wtf Fehler — Fallback zu yt-dlp: ${e.message}`);
|
||||
}
|
||||
return ytdlp.downloadTrack(opts);
|
||||
}
|
||||
|
||||
@@ -33,6 +18,11 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
|
||||
const result = await downloadTrack({
|
||||
jobId,
|
||||
artist: track.artistName || artist,
|
||||
// Drive the YTM/YouTube search + channel filter with THIS track's own artist, not the album
|
||||
// artist. On mixed-artist soundtracks/compilations (e.g. "Strategy" is TWICE, "Love, Maybe"
|
||||
// is MeloMance) the album-artist blob + its channel filter matches nothing. album_artist tag
|
||||
// still uses `albumArtist` below, so album grouping is unaffected.
|
||||
rawArtist: track.artistName || artist,
|
||||
albumArtist: artist,
|
||||
title: track.trackName || track.title,
|
||||
album: albumTitle,
|
||||
@@ -48,7 +38,9 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
|
||||
outputDir: CONFIG.musicPath,
|
||||
durationMs: track.trackTimeMillis || undefined,
|
||||
});
|
||||
if (result?.skipped) skipped++; else done++;
|
||||
if (result?.skipped) skipped++;
|
||||
else if (result?.notFound) log(jobId, `⚠️ Nicht eindeutig gefunden: "${track.trackName || track.title}" — Direktlink nutzen`);
|
||||
else done++;
|
||||
updateJob(jobId, { progress: Math.round(((done + skipped) / total) * 90) });
|
||||
} catch (e) {
|
||||
log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`);
|
||||
|
||||
+65
-129
@@ -17,7 +17,7 @@ function navidromeArtist(name) {
|
||||
.replace(/,\s*/g, ';');
|
||||
}
|
||||
|
||||
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
||||
// Download hi-res cover artwork (1000x1000) to cover.jpg, return path or null
|
||||
async function downloadArtwork(url, dir) {
|
||||
if (!url) return null;
|
||||
const dest = path.join(dir, 'cover.jpg');
|
||||
@@ -48,13 +48,67 @@ async function downloadArtwork(url, dir) {
|
||||
|
||||
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
||||
|
||||
function findExistingTrack(dir, trackPrefix, safeTitle, safeArtist) {
|
||||
// Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext"
|
||||
const names = safeArtist
|
||||
? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`]
|
||||
: [`${trackPrefix}${safeTitle}`];
|
||||
return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${ext}`)))
|
||||
.find(f => fs.existsSync(f)) || null;
|
||||
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
||||
const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
|
||||
|
||||
// Version markers that make a track a DISTINCT recording — brackets containing these must NOT be
|
||||
// stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the
|
||||
// base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed).
|
||||
const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/i;
|
||||
|
||||
// Robust duplicate detection: search case-insensitively across all album folders
|
||||
// under the artist folder matching firstArtist, compare normalized title ignoring
|
||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
||||
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
|
||||
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
|
||||
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
|
||||
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
|
||||
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
|
||||
.replace(/[/\\:*?"<>|_]/g, ' ')
|
||||
.replace(/[,.'!]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const normArtist = norm(firstArtist);
|
||||
const normTitle = norm(cleanTitle);
|
||||
// Accept both the raw cleanTitle and version without release suffix (e.g. "recognize" matches "recognize - Single")
|
||||
const titleVariants = new Set([normTitle, norm(cleanTitle.replace(_RELEASE_SUFFIX_RE, ''))]);
|
||||
|
||||
try {
|
||||
for (const ad of fs.readdirSync(musicRoot, { withFileTypes: true })) {
|
||||
if (!ad.isDirectory() || norm(ad.name) !== normArtist) continue;
|
||||
const artistPath = path.join(musicRoot, ad.name);
|
||||
try {
|
||||
for (const abd of fs.readdirSync(artistPath, { withFileTypes: true })) {
|
||||
if (!abd.isDirectory()) continue;
|
||||
const albumPath = path.join(artistPath, abd.name);
|
||||
try {
|
||||
for (const f of fs.readdirSync(albumPath)) {
|
||||
const ext = path.extname(f).slice(1).toLowerCase();
|
||||
if (!AUDIO_EXTS.includes(ext)) continue;
|
||||
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
||||
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
|
||||
// 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 candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
|
||||
for (const cand of candidates) {
|
||||
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) return path.join(albumPath, f);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch plain + synced lyrics from LRCLIB
|
||||
@@ -72,129 +126,11 @@ async function fetchLyrics(artist, title, album) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Normalize a track title for comparison: unify variant suffixes so that
|
||||
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
|
||||
function normalizeTitle(s) {
|
||||
return (s || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
// convert "title - variant" → "title (variant)" so both forms compare equal
|
||||
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
|
||||
// strip all remaining parens/brackets content for the base comparison
|
||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
||||
// strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match
|
||||
.replace(/[,.'!?]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// iTunes JP store returns localized genre names — map them to English equivalents
|
||||
const JP_GENRE_MAP = {
|
||||
'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop',
|
||||
'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz',
|
||||
'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap',
|
||||
'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal',
|
||||
'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk',
|
||||
'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues',
|
||||
'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy',
|
||||
'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family',
|
||||
};
|
||||
function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; }
|
||||
|
||||
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
|
||||
async function lookupItunesTrackMeta(artist, title) {
|
||||
try {
|
||||
// Search with base title (strip variant suffix) for better iTunes hit rate —
|
||||
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
|
||||
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||
const searchTitle = baseTitle !== title ? baseTitle : title;
|
||||
const searchArtist = artist.split(/[;,&]/)[0].trim();
|
||||
const fetchItunes = (country) => new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`;
|
||||
https.get(url, res => {
|
||||
let d = ''; res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
const normT = normalizeTitle(title);
|
||||
const normA = (searchArtist || '').toLowerCase().trim();
|
||||
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
|
||||
const firstWord = s => (s || '').toLowerCase().split(/[\s,;/]/)[0];
|
||||
const strictMatch = results => results.find(t =>
|
||||
normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA)
|
||||
);
|
||||
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
|
||||
|
||||
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
|
||||
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
|
||||
let match = strictMatch(jpResults);
|
||||
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists —
|
||||
// common anime/OST titles exist on both JP and US stores under different artists.
|
||||
// Only use it as a fallback when neither title nor artist contains Japanese characters.
|
||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
|
||||
let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
|
||||
if (!match) match = usMatch;
|
||||
if (!match) return {};
|
||||
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
|
||||
const rawGenre = (usMatch || match).primaryGenreName;
|
||||
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
|
||||
return {
|
||||
album: match.collectionName || '',
|
||||
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
|
||||
trackNum: match.trackNumber || undefined,
|
||||
totalTracks: match.trackCount || undefined,
|
||||
discNum: match.discNumber || undefined,
|
||||
discTotal: match.discCount || undefined,
|
||||
genre: genre,
|
||||
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
|
||||
durationMs: match.trackTimeMillis || undefined,
|
||||
itunesArtistName: match.artistName || undefined,
|
||||
};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// Fetch individual tracks for an iTunes collectionId
|
||||
async function fetchItunesTracks(collectionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
|
||||
https.get(url, res => {
|
||||
let data = '';
|
||||
res.on('data', d => data += d);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
|
||||
} catch { reject(new Error('iTunes API parse error')); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
|
||||
async function searchItunesAlbumTracks(artist, album) {
|
||||
return new Promise(resolve => {
|
||||
const q = encodeURIComponent(`${artist} ${album}`);
|
||||
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=jp&explicit=Yes`, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const results = JSON.parse(d).results || [];
|
||||
const match = results.find(r =>
|
||||
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
|
||||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
|
||||
);
|
||||
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
|
||||
else resolve([]);
|
||||
} catch { resolve([]); }
|
||||
});
|
||||
}).on('error', () => resolve([]));
|
||||
});
|
||||
}
|
||||
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
|
||||
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
|
||||
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||||
|
||||
module.exports = {
|
||||
sanitize, navidromeArtist,
|
||||
downloadArtwork, findExistingTrack,
|
||||
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -1,292 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const CONFIG = require('./config');
|
||||
const { log } = require('./jobs');
|
||||
const {
|
||||
sanitize, navidromeArtist,
|
||||
downloadArtwork, fetchLyrics, findExistingTrack,
|
||||
} = require('./metadata');
|
||||
const { embedMetadata } = require('./ytdlp');
|
||||
const { romajiForStorage } = require('./romaji');
|
||||
|
||||
const SQUID_BASE = 'https://qobuz.squid.wtf';
|
||||
|
||||
// Qobuz quality codes: mp3=5 (320kbps), flac=6 (16/44), hi-res=7 (24/96)
|
||||
const QUALITY_CODE = { mp3: '5', flac: '6', 'hi-res': '7' };
|
||||
|
||||
function squidExt() {
|
||||
return CONFIG.squidQuality === 'mp3' ? 'mp3' : 'flac';
|
||||
}
|
||||
|
||||
// ─── ALTCHA cookie cache ─────────────────────────────────────────────────────
|
||||
let _captchaCookie = null;
|
||||
let _captchaAt = 0;
|
||||
const CAPTCHA_TTL = 27 * 60 * 1000; // refresh 1 min before server's 28-min expiry
|
||||
|
||||
// Standard ALTCHA PoW solver: find number such that SHA-256(salt + number) == challenge
|
||||
async function solveAltcha(challenge) {
|
||||
const { algorithm = 'SHA-256', challenge: target, salt, maxnumber = 1_000_000 } = challenge;
|
||||
if (algorithm !== 'SHA-256') throw new Error(`ALTCHA: unsupported algorithm ${algorithm}`);
|
||||
const startMs = Date.now();
|
||||
|
||||
for (let number = 0; number <= maxnumber; number++) {
|
||||
const hash = crypto.createHash('sha256').update(salt + String(number)).digest('hex');
|
||||
if (hash === target) {
|
||||
return { number, time: Date.now() - startMs };
|
||||
}
|
||||
if (number % 10_000 === 0) await new Promise(r => setImmediate(r));
|
||||
}
|
||||
throw new Error('ALTCHA: no solution found within maxnumber');
|
||||
}
|
||||
|
||||
function httpsGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https') ? https : http;
|
||||
mod.get(url, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function httpsPost(url, body, extraHeaders) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'User-Agent': 'NovaSonic/1.0',
|
||||
...extraHeaders,
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getAltchaCookie() {
|
||||
if (_captchaCookie && Date.now() - _captchaAt < CAPTCHA_TTL) return _captchaCookie;
|
||||
|
||||
// 1. Fetch challenge — standard ALTCHA returns { algorithm, challenge, salt, signature, maxnumber }
|
||||
const chRes = await httpsGet(`${SQUID_BASE}/api/altcha/challenge`);
|
||||
if (chRes.status !== 200) throw new Error(`ALTCHA challenge HTTP ${chRes.status}: ${chRes.body.slice(0,200)}`);
|
||||
const chBody = JSON.parse(chRes.body);
|
||||
const challenge = chBody.parameters ?? chBody; // unwrap { parameters: {...} } if present
|
||||
log('altcha', `🔐 ALTCHA challenge: alg=${challenge.algorithm} max=${challenge.maxnumber}`);
|
||||
|
||||
// 2. Solve PoW
|
||||
const solution = await solveAltcha(challenge);
|
||||
log('altcha', `🔐 ALTCHA gelöst in ${solution.time}ms (number=${solution.number})`);
|
||||
|
||||
// 3. Verify — standard ALTCHA payload: base64(JSON { algorithm, challenge, number, salt, signature })
|
||||
const payloadObj = {
|
||||
algorithm: challenge.algorithm || 'SHA-256',
|
||||
challenge: challenge.challenge,
|
||||
number: solution.number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
};
|
||||
const payload = Buffer.from(JSON.stringify(payloadObj)).toString('base64');
|
||||
const vRes = await httpsPost(`${SQUID_BASE}/api/altcha/verify`, { payload });
|
||||
if (vRes.status !== 200) throw new Error(`ALTCHA verify HTTP ${vRes.status}: ${vRes.body.slice(0,200)}`);
|
||||
|
||||
// Parse Set-Cookie: captcha_verified_at=<value>; ...
|
||||
const setCookie = [vRes.headers['set-cookie']].flat().filter(Boolean);
|
||||
const cookiePair = setCookie.map(c => c.split(';')[0]).find(c => c.startsWith('captcha_verified_at='));
|
||||
if (!cookiePair) throw new Error('ALTCHA verify: no cookie in response');
|
||||
|
||||
_captchaCookie = cookiePair;
|
||||
_captchaAt = Date.now();
|
||||
return _captchaCookie;
|
||||
}
|
||||
|
||||
async function squidApiGet(endpoint) {
|
||||
const cookie = await getAltchaCookie();
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${SQUID_BASE}${endpoint}`;
|
||||
const parsed = new URL(url);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'Token-Country': 'US',
|
||||
'User-Agent': 'NovaSonic/1.0',
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Search Qobuz via squid.wtf, return best matching track object or null
|
||||
async function squidSearch(artist, title, durationMs) {
|
||||
const query = encodeURIComponent(`${artist} ${title}`);
|
||||
const res = await squidApiGet(`/api/get-music?q=${query}&offset=0`);
|
||||
if (res.status !== 200) throw new Error(`squid search HTTP ${res.status}`);
|
||||
|
||||
const json = JSON.parse(res.body);
|
||||
// Handle both {data:{tracks:{items:[...]}}} and {tracks:{items:[...]}} wrappers
|
||||
const items = json?.data?.tracks?.items ?? json?.tracks?.items ?? [];
|
||||
if (!items.length) return null;
|
||||
|
||||
const norm = s => (s || '').toLowerCase().trim();
|
||||
const normT = norm(title);
|
||||
const normA = norm(artist).split(/[,\s]/)[0];
|
||||
|
||||
// Score candidates: prefer exact title match + matching artist + duration proximity
|
||||
const scored = items.map(t => {
|
||||
const tTitle = norm(t.title);
|
||||
const tArtist = norm(t.performer?.name || '');
|
||||
const tDuration = (t.duration || 0) * 1000; // Qobuz uses seconds
|
||||
|
||||
let score = 0;
|
||||
if (tTitle === normT) score += 100;
|
||||
else if (tTitle.includes(normT)) score += 50;
|
||||
if (tArtist.startsWith(normA)) score += 30;
|
||||
if (durationMs && tDuration) {
|
||||
const diff = Math.abs(tDuration - durationMs);
|
||||
if (diff < 3000) score += 20; // within 3 s
|
||||
else if (diff < 10000) score += 10; // within 10 s
|
||||
else if (diff > 30000) score -= 20; // way off
|
||||
}
|
||||
return { track: t, score };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0];
|
||||
// Reject if score is too low (no reasonable match)
|
||||
if (best.score < 30) return null;
|
||||
return best.track;
|
||||
}
|
||||
|
||||
// Stream a URL to destPath (handles redirects via Location header)
|
||||
async function downloadUrlToFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryFetch = (u, hops) => {
|
||||
if (hops > 5) { reject(new Error('Too many redirects')); return; }
|
||||
const mod = u.startsWith('https') ? https : http;
|
||||
const parsed = new URL(u);
|
||||
const opts = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (u.startsWith('https') ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'NovaSonic/1.0' },
|
||||
};
|
||||
const req = mod.request(opts, res => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) {
|
||||
res.resume();
|
||||
tryFetch(res.headers.location, hops + 1);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`Download HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve));
|
||||
file.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
};
|
||||
tryFetch(url, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Download a single track via squid.wtf / Qobuz.
|
||||
// Returns { success: true, dir, skipped? } on success, null if track not found on Qobuz.
|
||||
async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) {
|
||||
const mainArtist = albumArtist || artist;
|
||||
// Use romaji for folder/filename — same as ytdlp path so paths are consistent
|
||||
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||
const titleRomaji = romajiForStorage(title) || title;
|
||||
const albumRomaji = album ? (romajiForStorage(album) || album) : null;
|
||||
const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim());
|
||||
const safeAlbum = sanitize(albumRomaji || 'Singles');
|
||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const safeTitle = sanitize(titleRomaji);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
const ext = squidExt();
|
||||
|
||||
const existing = findExistingTrack(dir, trackPrefix, safeTitle);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
}
|
||||
|
||||
log(jobId, `🦑 squid.wtf: suche "${artist} – ${title}"`);
|
||||
|
||||
// Solve ALTCHA if needed (cached for 27 min)
|
||||
await getAltchaCookie();
|
||||
|
||||
// Find track on Qobuz
|
||||
const searchArtist = mainArtist.replace(/[^\p{L}\p{N}\s,&\-]/gu, '').trim();
|
||||
const track = await squidSearch(searchArtist, title, durationMs);
|
||||
if (!track) {
|
||||
log(jobId, `⚠️ squid.wtf: "${title}" nicht auf Qobuz gefunden`);
|
||||
return null; // signals caller to fall back to yt-dlp
|
||||
}
|
||||
|
||||
// Get download URL
|
||||
const quality = QUALITY_CODE[CONFIG.squidQuality] || '5';
|
||||
const dlRes = await squidApiGet(`/api/download-music?track_id=${track.id}&quality=${quality}`);
|
||||
if (dlRes.status !== 200) throw new Error(`squid download-music HTTP ${dlRes.status}`);
|
||||
|
||||
const dlJson = JSON.parse(dlRes.body);
|
||||
const streamUrl = dlJson?.data?.url ?? dlJson?.url;
|
||||
if (!streamUrl) throw new Error('squid: no download URL in response');
|
||||
|
||||
// Download to a temp file, then embed metadata
|
||||
const audioFile = path.join(dir, `${trackPrefix}${safeTitle}.${ext}`);
|
||||
const tmpFile = audioFile + '.squidtmp';
|
||||
log(jobId, `⬇️ Lade herunter (Qobuz ${CONFIG.squidQuality.toUpperCase()})…`);
|
||||
try {
|
||||
await downloadUrlToFile(streamUrl, tmpFile);
|
||||
fs.renameSync(tmpFile, audioFile);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Use Qobuz artwork if no better URL was passed in
|
||||
const finalArtwork = artworkUrl || track.album?.image?.large || track.album?.image?.small || '';
|
||||
|
||||
return embedMetadata({
|
||||
jobId, audioFile, dir, trackPrefix, safeTitle,
|
||||
rawArtist, artist, mainArtist, album, year, trackNum, totalTracks,
|
||||
discNum, discTotal, compilation, genre,
|
||||
artworkUrl: finalArtwork, explicit, title,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { downloadTrack };
|
||||
@@ -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 };
|
||||
+114
-104
@@ -13,16 +13,12 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||
|
||||
function buildTitleKeywords(title) {
|
||||
// For Japanese titles: \w strips all kana/kanji, leaving nothing useful.
|
||||
// Instead use the first whitespace-delimited segment as a literal keyword so
|
||||
// the YTM --match-filter requires the video title to contain it.
|
||||
if (JP_RE.test(title)) {
|
||||
const firstWord = (title.split(/[\s ]/)[0] || '').slice(0, 12);
|
||||
const escaped = firstWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return escaped ? [escaped] : [];
|
||||
}
|
||||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
||||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
||||
// reject the correct video. Channel + duration are the quality gates instead.
|
||||
if (JP_RE.test(title)) return [];
|
||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']);
|
||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
|
||||
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
||||
const lower = (title || '').toLowerCase();
|
||||
const strippedTitle = (title || '').replace(/\([^)]*\)/g, '').trim();
|
||||
@@ -77,25 +73,27 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
}
|
||||
}
|
||||
|
||||
// Folder / filename: romaji of the resolved artist (iTunes name if available — already in English,
|
||||
// otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search.
|
||||
const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist;
|
||||
const artistRomaji = romajiForStorage(artist) || artist;
|
||||
const titleRomaji = romajiForStorage(title) || title;
|
||||
const albumRomaji = album ? (romajiForStorage(album) || album) : null;
|
||||
const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim());
|
||||
const safeAlbum = sanitize(albumRomaji || 'Singles');
|
||||
// Strip release-type suffixes Spotify appends to track names (e.g. "recognize - Single" → "recognize").
|
||||
// Only strip trailing "- word(s)" when those words are release-type qualifiers or bare years, not
|
||||
// real sub-titles (which tend to be longer phrases or start with "feat"/"ft").
|
||||
// Used for BOTH the filename ("01 - Artist - Title", no "- Single") and the YTM search query.
|
||||
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 cleanTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
|
||||
|
||||
// Folder / filename: use original names (Japanese or whatever was passed in).
|
||||
// Romaji goes ONLY into sort tags (TITLESORT, ARTISTSORT, etc.) via embedMetadata.
|
||||
const safeArtist = sanitize((mainArtist || '').split(/[,;&]/)[0].trim());
|
||||
const safeAlbum = sanitize(album || 'Singles');
|
||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const safeTitle = sanitize(titleRomaji);
|
||||
const safeTitle = sanitize(cleanTitle);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
||||
// Use track artist (not albumArtist) so all credited artists appear in the filename
|
||||
const safeArtistForFile = sanitize(artistRomaji.replace(/;/g, ','));
|
||||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||
|
||||
const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile);
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
@@ -110,8 +108,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
|
||||
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const variantFilter = buildVariantFilter(title);
|
||||
const titleKws = buildTitleKeywords(title);
|
||||
const searchTitle = cleanTitle;
|
||||
|
||||
const variantFilter = buildVariantFilter(searchTitle);
|
||||
const titleKws = buildTitleKeywords(searchTitle);
|
||||
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
||||
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
||||
|
||||
@@ -122,19 +122,17 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
||||
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||
// yt-dlp fills `artist` (music metadata) on YouTube "Art Tracks" — the label-provided official
|
||||
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
|
||||
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
|
||||
// none. Used as an OR alternative to the channel check so those official uploads still pass strict.
|
||||
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||
|
||||
// When the title is Japanese (keywords stripped to nothing) OR when we have an album that
|
||||
// provides better search context, include the album's first two words in the search query.
|
||||
// This helps YTM rank the official OST/album track above fan covers and romaji-subtitle uploads.
|
||||
// Only use album words that are ASCII (romaji/English) — Japanese album words don't help YTM ranking.
|
||||
const hasJpTitle = JP_RE.test(title);
|
||||
const albumWords = (album && album !== 'Singles' && album !== 'Unknown')
|
||||
? album.split(/\s+/).filter(w => /^[A-Za-z0-9]/.test(w)).slice(0, 2).join(' ')
|
||||
: '';
|
||||
const albumQuerySuffix = (hasJpTitle || (albumWords && titleKws.length <= 1)) && albumWords
|
||||
? ' ' + albumWords : '';
|
||||
|
||||
const query = `${ytmArtist} - ${title}${albumQuerySuffix}`;
|
||||
// Use a space (not " - ") between artist and title: this matches how users type
|
||||
// in the YTM search box and ranks the exact official track first. A dash skews
|
||||
// YTM ranking toward a same-named artist's full discography.
|
||||
const query = `${ytmArtist} ${searchTitle}`;
|
||||
|
||||
const baseArgs = [
|
||||
'--extract-audio',
|
||||
@@ -144,6 +142,12 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--newline',
|
||||
// Hard upper size limit — prevents saving a wrong 28-min video as a ~52 MB mp3
|
||||
'--max-filesize', '30M',
|
||||
// Use the web_music player client (falling back to default) so YouTube Music
|
||||
// "Art Track" / topic uploads are reachable. The android_vr client returns
|
||||
// "This video is not available" for many YTM tracks that play fine in the browser.
|
||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||
];
|
||||
|
||||
const spawnYtdlp = (args) => new Promise((resolve, reject) => {
|
||||
@@ -162,15 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
.filter(l => l.trim() && !l.startsWith('[debug]'))
|
||||
.forEach(l => log(jobId, l.trim()));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.on('close', code =>
|
||||
(code === 0 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`))
|
||||
);
|
||||
proc.on('error', resolve);
|
||||
proc.on('close', code => {
|
||||
// Treat all non-zero exits as "no result" so the cascade continues.
|
||||
// Fatal errors (network, bad args) are logged via stderr above; hasAudio() decides outcome.
|
||||
if (code !== 0 && code !== 101) log(jobId, `⚠️ yt-dlp exit ${code}: ${stderr.slice(-200).trim()}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Use original (raw) artist for YTM URL — better ranking since channel is in original language.
|
||||
// Append the "Songs" filter param so results are restricted to YTM's Songs shelf
|
||||
// (no videos/playlists/clips) — the documented way to song-search YTM. This is cleaner
|
||||
// for normal tracks; for ambiguous artist names yt-dlp's search may still not surface the
|
||||
// exact official track (a known extractor limitation — use the direct link in that case).
|
||||
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
|
||||
// Use original (raw) artist for YTM URL — better ranking since channel is in original language
|
||||
const ytmQuery = `${rawArtist || mainArtist} - ${title}${albumQuerySuffix}`;
|
||||
const ytmQuery = `${rawArtist || mainArtist} ${searchTitle}`;
|
||||
const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`;
|
||||
const expectedAudio = path.join(dir, `${fileBase}.${CONFIG.audioFmt}`);
|
||||
const hasAudio = () => fs.existsSync(expectedAudio);
|
||||
@@ -180,34 +191,39 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
||||
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
||||
const audioFile = hasAudio() ? expectedAudio : null;
|
||||
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
|
||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Fetch YTM search result URLs as a flat list (no channel expansion) and return only
|
||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||
// result and treating its uploads as the search result set.
|
||||
//
|
||||
// yt-dlp expands "url_transparent" artist channel entries even under --flat-playlist,
|
||||
// printing the channel's video URLs instead of the channel URL. We detect this by
|
||||
// watching for [youtube:tab] in output (which only fires during channel/tab extraction)
|
||||
// and return [] so the caller falls through to the ytsearch fallback.
|
||||
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
|
||||
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
|
||||
// return [] so the caller falls through to the ytsearch fallback.
|
||||
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
||||
// page processing, not just channel expansion, and would wrongly return [] always.
|
||||
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
||||
const urls = [];
|
||||
let channelExpanded = false;
|
||||
const checkExpansion = (text) => {
|
||||
if (text.includes('[youtube:tab]') || text.includes('/browse/UC')) channelExpanded = true;
|
||||
};
|
||||
const proc = spawn(CONFIG.ytdlpBin, [
|
||||
ytmUrl,
|
||||
'--flat-playlist',
|
||||
'--playlist-items', `1-${maxItems}`,
|
||||
'--print', 'webpage_url',
|
||||
'--no-warnings',
|
||||
// Without the web_music client, yt-dlp parses the YTM search page as a
|
||||
// generic YouTube search and returns unrelated videos instead of the
|
||||
// native Songs shelf (where the actual artist track lives).
|
||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||||
proc.stdout.on('data', d => {
|
||||
const text = d.toString();
|
||||
checkExpansion(text);
|
||||
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
|
||||
if (!channelExpanded) {
|
||||
text.split('\n').forEach(line => {
|
||||
const u = line.trim();
|
||||
@@ -215,15 +231,33 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
});
|
||||
}
|
||||
});
|
||||
proc.stderr.on('data', d => checkExpansion(d.toString()));
|
||||
proc.on('error', () => resolve([]));
|
||||
proc.on('close', () => resolve(channelExpanded ? [] : urls));
|
||||
proc.on('close', () => {
|
||||
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
|
||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
|
||||
resolve(channelExpanded ? [] : urls);
|
||||
});
|
||||
});
|
||||
|
||||
const mf = (dur, variant, strict = false) => {
|
||||
const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : '';
|
||||
return ['--match-filter',
|
||||
`${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`];
|
||||
let durPart;
|
||||
if (dur) {
|
||||
durPart = `duration > ${dur.min} & duration < ${dur.max} & `;
|
||||
} else if (durationMs) {
|
||||
// Even when relaxing the window (Mittelweg), keep a 2× cap so a 28-min video can't slip through
|
||||
durPart = `duration < ${Math.round(durationMs / 1000 * 2)} & `;
|
||||
} else {
|
||||
durPart = '';
|
||||
}
|
||||
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
||||
// Non-strict, or no artist constraint (short / Japanese name): single filter.
|
||||
if (!strict || !artistChannelPart) return ['--match-filter', base];
|
||||
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
|
||||
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
|
||||
return [
|
||||
'--match-filter', `${base}${artistChannelPart}`,
|
||||
'--match-filter', `${base}${artistMetaPart}`,
|
||||
];
|
||||
};
|
||||
|
||||
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
|
||||
@@ -242,7 +276,7 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
|
||||
if (durationMs && durationMs > 30000) {
|
||||
const durationSec = Math.round(durationMs / 1000);
|
||||
const margin = Math.max(15, Math.round(durationSec * 0.15));
|
||||
const margin = Math.max(10, Math.round(durationSec * 0.10));
|
||||
const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin };
|
||||
|
||||
log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`);
|
||||
@@ -262,32 +296,19 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// 4. YT top-10 — no channel check
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||
}
|
||||
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
|
||||
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
|
||||
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
|
||||
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
|
||||
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all — no duration check
|
||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…');
|
||||
await tryYtm(0, mf(null, true));
|
||||
// 5. YTM all — no duration, but channel + title required
|
||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
||||
await tryYtm(0, mf(null, true, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-5 — no duration check
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 7. YTM all — title only (no variant filter)
|
||||
log(jobId, '⚠️ Letzter gefilterter Versuch…');
|
||||
await tryYtm(0, mf(null, false));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 8. YT top-5 — title only
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 9. YTM — no filter at all, take first video result
|
||||
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
|
||||
await tryYtm(3, []);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 10. YT — no filter
|
||||
await spawnYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
|
||||
// 6. YT top-10 — no duration, but channel + title required
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
|
||||
}
|
||||
} else {
|
||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
||||
@@ -305,33 +326,18 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// 4. YT top-5 — no channel check
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all — title only
|
||||
log(jobId, '⚠️ Letzter gefilterter Versuch…');
|
||||
await tryYtm(0, mf(null, false));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-5 — title only
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 7. YTM — first video result, no filter
|
||||
log(jobId, '⚠️ Letzter Versuch: keine Filter…');
|
||||
await tryYtm(3, []);
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 8. YT — no filter
|
||||
await spawnYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]);
|
||||
}
|
||||
// Mittelweg (no-duration): after step 4, do NOT fall through to title-only or
|
||||
// no-filter grabs — those pull wrong covers (observed: Yorushika → Piano Echoes).
|
||||
// Fail cleanly, same as the with-duration branch. Use the Direktlink instead.
|
||||
}
|
||||
|
||||
const audioFile = hasAudio() ? expectedAudio : null;
|
||||
if (!audioFile) {
|
||||
log(jobId, '⚠️ Keine Audio-Datei gefunden');
|
||||
return { success: true, dir };
|
||||
log(jobId, '⚠️ Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen');
|
||||
return { success: false, notFound: true, dir };
|
||||
}
|
||||
|
||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
|
||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
|
||||
}
|
||||
|
||||
// Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths
|
||||
@@ -350,9 +356,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
||||
if (hasCover) ffArgs.push('-map', '1:v');
|
||||
// Main tags: store original text (Japanese or whatever was passed in).
|
||||
// rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English).
|
||||
// Use rawArtist for the stored tag so Apple Music / other apps see the authentic original.
|
||||
// Use rawArtist for the per-track artist tag; mainArtist for album_artist so all tracks
|
||||
// in an album get the same value (prevents Navidrome album-splitting).
|
||||
const tagArtist = navidromeArtist(rawArtist || artist);
|
||||
const tagAlbumArtist = (rawArtist || mainArtist).replace(/\s+&\s+/g, ', ');
|
||||
const tagAlbumArtist = mainArtist.replace(/\s+&\s+/g, ', ');
|
||||
// Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.)
|
||||
// artist is already the iTunes-resolved English name when available — use directly (more accurate
|
||||
// than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback.
|
||||
@@ -368,7 +375,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
||||
'-metadata', `artist=${tagArtist}`,
|
||||
'-metadata', `album_artist=${tagAlbumArtist}`,
|
||||
);
|
||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||
// Always write an album tag, defaulting to "Singles" so it matches the on-disk folder
|
||||
// (album || 'Singles'). Without this, no-album tracks land in a "Singles" folder but show
|
||||
// as "[Unknown Album]" in Navidrome — a tag/folder mismatch that breaks the metadata editor.
|
||||
ffArgs.push('-metadata', `album=${album || 'Singles'}`);
|
||||
// Sort tags — only write when they differ from main (no-op for non-Japanese tracks)
|
||||
if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`);
|
||||
if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`);
|
||||
|
||||
+316
-94
@@ -13,11 +13,14 @@ const { execSync, spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const CONFIG = require('./lib/config');
|
||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
|
||||
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
||||
const dz = require('./lib/deezer');
|
||||
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
|
||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
||||
|
||||
@@ -119,7 +122,12 @@ function _findFileByStem(dir, titleStem, depth = 0) {
|
||||
// Match: exact, or "Artist - Title" ending with our title stem
|
||||
if (bareStem === normTitle ||
|
||||
bareStem.endsWith(' - ' + normTitle) ||
|
||||
bareStem.endsWith(normTitle)) {
|
||||
bareStem.endsWith(normTitle) ||
|
||||
// Release-suffix desync: Navidrome's title-derived stem ("… - recognize")
|
||||
// may lack the "- Single"/"- EP" suffix the on-disk filename still carries
|
||||
// ("… - recognize - single"), or vice-versa. Match on the structured prefix.
|
||||
bareStem.startsWith(normTitle + ' - ') ||
|
||||
normTitle.startsWith(bareStem + ' - ')) {
|
||||
return path.join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
@@ -226,6 +234,47 @@ app.delete('/api/romaji-overrides/:kanji', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ──
|
||||
// Fixes same-name collisions where NovaSonic would otherwise guess the wrong Deezer artist
|
||||
// (fans / top songs / albums / image). Keyed by the Navidrome artist id.
|
||||
const _artistOverridesFile = path.join(CONFIG.dataPath, 'deezer-artist-overrides.json');
|
||||
function _loadArtistOverrides() {
|
||||
try { return JSON.parse(fs.readFileSync(_artistOverridesFile, 'utf8')); } catch { return {}; }
|
||||
}
|
||||
function _saveArtistOverrides(map) {
|
||||
try {
|
||||
fs.mkdirSync(CONFIG.dataPath, { recursive: true });
|
||||
fs.writeFileSync(_artistOverridesFile, JSON.stringify(map, null, 2), 'utf8');
|
||||
} catch (e) { console.error('Failed to save artist overrides:', e.message); }
|
||||
}
|
||||
|
||||
// GET — full map { ndArtistId: { deezerArtistId, name, image, fanCount } }
|
||||
app.get('/api/artist-overrides', (_req, res) => res.json(_loadArtistOverrides()));
|
||||
|
||||
// POST — upsert a pin { ndArtistId, deezerArtistId, name, image, fanCount }
|
||||
app.post('/api/artist-overrides', (req, res) => {
|
||||
const { ndArtistId, deezerArtistId, name, image, fanCount } = req.body || {};
|
||||
if (!ndArtistId || !deezerArtistId) return res.status(400).json({ error: 'ndArtistId and deezerArtistId required' });
|
||||
const map = _loadArtistOverrides();
|
||||
map[String(ndArtistId)] = {
|
||||
deezerArtistId: String(deezerArtistId),
|
||||
name: name || '',
|
||||
image: image || '',
|
||||
fanCount: (fanCount ?? null),
|
||||
};
|
||||
_saveArtistOverrides(map);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||||
const id = decodeURIComponent(req.params.ndArtistId);
|
||||
const map = _loadArtistOverrides();
|
||||
delete map[id];
|
||||
_saveArtistOverrides(map);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/romaji-convert — convert an array of strings to romaji
|
||||
app.post('/api/romaji-convert', (req, res) => {
|
||||
const { texts } = req.body;
|
||||
@@ -269,7 +318,7 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
|
||||
const batch = tracks.slice(i, i + CONC);
|
||||
const batchRes = await Promise.all(batch.map(async t => {
|
||||
const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
|
||||
const meta = await lookupItunesTrackMeta(searchArtist, t.trackName || '');
|
||||
const meta = await dz.lookupTrackMeta(searchArtist, t.trackName || '');
|
||||
return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' };
|
||||
}));
|
||||
results.push(...batchRes);
|
||||
@@ -277,48 +326,83 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
// ─── ITUNES METADATA LOOKUP (must be before the proxy route) ─────────────────
|
||||
app.get('/api/itunes/lookup', async (req, res, next) => {
|
||||
// ─── METADATA-EDITOR AUTOFILL ────────────────────────────────────────────────
|
||||
// Fills the editor from Deezer given artist + title (replaces the old iTunes autofill).
|
||||
app.get('/api/meta/lookup', async (req, res) => {
|
||||
const { artist, title } = req.query;
|
||||
// ID-based lookups (?id=...) are not for track meta — let them fall through to the proxy.
|
||||
if (!artist || !title) return next();
|
||||
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||
try {
|
||||
const meta = await lookupItunesTrackMeta(artist, title);
|
||||
// Editor "Deezer" button = Deezer only (there is a separate "MusicBrainz" button). The combined
|
||||
// Deezer→MusicBrainz→clean resolver (tm) is used by the DOWNLOAD paths, not this manual autofill.
|
||||
const meta = await dz.lookupTrackMeta(artist, title);
|
||||
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
|
||||
res.json({
|
||||
found: true,
|
||||
title: meta.trackName || undefined,
|
||||
artist: meta.metaArtistName || undefined,
|
||||
albumArtist: meta.albumArtist || undefined,
|
||||
album: meta.album || undefined,
|
||||
year: meta.year || undefined,
|
||||
trackNum: meta.trackNum || undefined,
|
||||
totalTracks: meta.totalTracks || undefined,
|
||||
discNum: meta.discNum || undefined,
|
||||
genre: meta.genre || undefined,
|
||||
coverUrl: meta.artworkUrl || undefined,
|
||||
artist: meta.itunesArtistName || undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[itunes/lookup]', e.message);
|
||||
console.error('[meta/lookup]', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ITUNES PROXY ─────────────────────────────────────────────────────────────
|
||||
// Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here.
|
||||
// Note: /api/itunes/lookup is handled above — proxy only covers /search and id-based /lookup.
|
||||
app.get('/api/itunes/:endpoint', (req, res) => {
|
||||
const allowed = new Set(['search', 'lookup']);
|
||||
if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' });
|
||||
// Deezer API proxy — the metadata source for search, charts, album/artist lookups (Apple's /search
|
||||
// returns 403 from server IPs). Free, no key, returns explicit versions + artist images + durations.
|
||||
// Covers /search, /search/album, /search/artist, /album/{id}, /artist/{id}/albums, /chart/…, etc.
|
||||
// Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and
|
||||
// crashes the server at startup, taking every endpoint down with it.
|
||||
app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
|
||||
const sub = String(req.params[0] || '').replace(/[^a-zA-Z0-9/_-]/g, '');
|
||||
const qs = new URLSearchParams(req.query).toString();
|
||||
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
|
||||
const target = `https://api.deezer.com/${sub}${qs ? '?' + qs : ''}`;
|
||||
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(upstream.statusCode || 200);
|
||||
upstream.pipe(res);
|
||||
}).on('error', e => res.status(502).json({ error: e.message }));
|
||||
});
|
||||
|
||||
// Server-side events cache. Ticketmaster's free `keyword` search is flaky — for the same artist
|
||||
// it intermittently returns a partial subset (or []), so back-to-back calls disagree (web showed 7,
|
||||
// a later live call got only 2). Caching on the BACKEND (not per-client) keeps every client
|
||||
// consistent, and we PREFER THE LARGER recent result: a transient partial/empty response never
|
||||
// shrinks a good list. TTL bounds staleness (a genuinely-dropped show self-heals within an hour).
|
||||
const _eventsCache = new Map(); // artistLower → { ts, data }
|
||||
const _EVENTS_TTL = 60 * 60 * 1000; // 1h
|
||||
|
||||
app.get('/api/events/:artist', (req, res) => {
|
||||
const key = CONFIG.ticketmasterKey;
|
||||
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
|
||||
const artist = req.params.artist;
|
||||
const cacheKey = artist.toLowerCase();
|
||||
const cached = _eventsCache.get(cacheKey);
|
||||
const fresh = cached && (Date.now() - cached.ts < _EVENTS_TTL);
|
||||
|
||||
// Serve the cached list, but only when it wouldn't hide a better live result. When we fall back
|
||||
// to cache on a TM error/partial, we DON'T bump ts — so the cache still expires on schedule and
|
||||
// self-heals instead of pinning stale events forever.
|
||||
const respondWithCacheFallback = (liveEvents) => {
|
||||
if (fresh && cached.data.length > liveEvents.length) {
|
||||
console.log(`[events] ${artist}: live=${liveEvents.length} < cache=${cached.data.length} → serving cache`);
|
||||
return res.json(cached.data);
|
||||
}
|
||||
if (liveEvents.length > 0 || !fresh) {
|
||||
_eventsCache.set(cacheKey, { ts: Date.now(), data: liveEvents });
|
||||
return res.json(liveEvents);
|
||||
}
|
||||
// live empty but cache still fresh → serve cache
|
||||
return res.json(cached.data);
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); // 2024-06-08T12:00:00Z
|
||||
const url = `https://app.ticketmaster.com/discovery/v2/events.json` +
|
||||
`?keyword=${encodeURIComponent(artist)}&classificationName=music` +
|
||||
@@ -332,15 +416,18 @@ app.get('/api/events/:artist', (req, res) => {
|
||||
const data = JSON.parse(body);
|
||||
if (upstream.statusCode !== 200 || data.fault || data.errors) {
|
||||
console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200));
|
||||
return res.json([]);
|
||||
return respondWithCacheFallback([]);
|
||||
}
|
||||
const raw = data?._embedded?.events || [];
|
||||
const events = raw.map(e => {
|
||||
const venue = e._embedded?.venues?.[0] || {};
|
||||
const start = e.dates?.start || {};
|
||||
const dt = start.dateTime || (start.localDate
|
||||
// Prefer the venue-LOCAL date/time (localDate + localTime) over the UTC `dateTime` —
|
||||
// otherwise a 20:00 local show is shown as its 08:00Z UTC instant. Sent as a naive local
|
||||
// datetime string (no zone); the clients render it as-is.
|
||||
const dt = (start.localDate
|
||||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||
: null);
|
||||
: start.dateTime) || null;
|
||||
const soldOut = e.dates?.status?.code === 'offsale' ||
|
||||
e.dates?.status?.code === 'cancelled';
|
||||
return {
|
||||
@@ -353,11 +440,11 @@ app.get('/api/events/:artist', (req, res) => {
|
||||
offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [],
|
||||
};
|
||||
}).filter(e => e.datetime);
|
||||
console.log(`[events] ${artist}: ${events.length} events`);
|
||||
res.json(events);
|
||||
} catch(err) { console.error('[events] parse error:', err); res.json([]); }
|
||||
console.log(`[events] ${artist}: ${events.length} events (live)`);
|
||||
respondWithCacheFallback(events);
|
||||
} catch(err) { console.error('[events] parse error:', err); respondWithCacheFallback([]); }
|
||||
});
|
||||
}).on('error', e => { console.error('[events] request error:', e.message); res.json([]); });
|
||||
}).on('error', e => { console.error('[events] request error:', e.message); respondWithCacheFallback([]); });
|
||||
});
|
||||
|
||||
// Debug-only: returns raw Ticketmaster response so you can see exactly what the API sends back.
|
||||
@@ -380,13 +467,115 @@ app.get('/api/events-debug/:artist', (req, res) => {
|
||||
}).on('error', e => res.json({ error: e.message }));
|
||||
});
|
||||
|
||||
// ── NovaSonic Android APK distribution ──────────────────────────────────────
|
||||
// The web app offers a "download the Android app" button that points here, so co-users always get
|
||||
// the latest APK without the maintainer hand-delivering it. Two sources, tried in order:
|
||||
// 1) A FIXED Gitea release (default tag "apk-latest") whose .apk asset you overwrite each build —
|
||||
// constant ~30 MB, uploadable from anywhere. The BACKEND fetches it server-side (same LAN as
|
||||
// Gitea), so Gitea can stay internal-only and the token never reaches the browser.
|
||||
// env: GITEA_URL (internal base, e.g. http://gitea:3000), GITEA_REPO (Docker/NovaSonic),
|
||||
// GITEA_TOKEN (read token; only needed for a private repo), GITEA_APK_TAG (default apk-latest)
|
||||
// 2) Fallback: a local file in the writable data volume:
|
||||
// docker cp app-release.apk novasonic-backend:/data/novasonic.apk
|
||||
// (optional /data/novasonic.version.txt with a version string)
|
||||
const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk');
|
||||
const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt');
|
||||
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
|
||||
const GITEA_REPO = process.env.GITEA_REPO || '';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||
const GITEA_APK_TAG = process.env.GITEA_APK_TAG || 'apk-latest';
|
||||
|
||||
function _giteaApiJson(pathAndQuery) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!GITEA_URL || !GITEA_REPO) return reject(new Error('gitea not configured'));
|
||||
const u = new URL(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}${pathAndQuery}`);
|
||||
const lib = u.protocol === 'https:' ? https : http;
|
||||
const headers = { 'Accept': 'application/json', 'User-Agent': 'NovaSonic' };
|
||||
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
|
||||
lib.get(u, { headers }, r => {
|
||||
let body = '';
|
||||
r.on('data', d => body += d);
|
||||
r.on('end', () => {
|
||||
if (r.statusCode < 200 || r.statusCode >= 300) return reject(new Error(`gitea ${r.statusCode}`));
|
||||
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
res.json({
|
||||
status: 'ok', ytdlpVersion,
|
||||
musicPath: CONFIG.musicPath,
|
||||
audioFormat: CONFIG.audioFmt,
|
||||
downloadBackend: CONFIG.downloadBackend,
|
||||
squidQuality: CONFIG.squidQuality,
|
||||
activeJobs: activeDownloads,
|
||||
queueLength: downloadQueue.length,
|
||||
spotify: { configured: true },
|
||||
@@ -508,27 +697,27 @@ app.post('/api/youtube/resolve', async (req, res) => {
|
||||
artworkUrl: entries[0]?.thumbnail || '', tracks });
|
||||
}
|
||||
|
||||
// Single video — enrich with iTunes
|
||||
// Single video — enrich with Deezer
|
||||
const e = entries[0];
|
||||
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
|
||||
const title = e.title || '';
|
||||
const sourceUrl = e.webpage_url || url;
|
||||
let itunes = {};
|
||||
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
let meta = {};
|
||||
try { meta = await tm.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
|
||||
|
||||
const artist = itunes.artist || rawArtist;
|
||||
const artworkUrl = itunes.artworkUrl || e.thumbnail || '';
|
||||
const artist = meta.artist || rawArtist;
|
||||
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
|
||||
return res.json({
|
||||
type: 'track', name: title, artist, artworkUrl,
|
||||
tracks: [{
|
||||
trackName: title,
|
||||
artistName: artist,
|
||||
artworkUrl: artworkUrl,
|
||||
collectionName: itunes.album || '',
|
||||
releaseDate: itunes.year ? String(itunes.year) : '',
|
||||
trackNumber: itunes.trackNum || undefined,
|
||||
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
|
||||
genre: itunes.genre || '',
|
||||
collectionName: meta.album || '',
|
||||
releaseDate: meta.year ? String(meta.year) : '',
|
||||
trackNumber: meta.trackNum || undefined,
|
||||
durationMs: meta.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
|
||||
genre: meta.genre || '',
|
||||
sourceUrl,
|
||||
}],
|
||||
});
|
||||
@@ -568,47 +757,67 @@ app.post('/api/download', async (req, res) => {
|
||||
discTotal: sp.discCount || bodyDiscTotal || undefined,
|
||||
genre: sp.primaryGenreName || sp.genre || undefined,
|
||||
artworkUrl: spArt, explicit: sp.trackExplicitness === 'explicit' || !!explicit,
|
||||
durationMs: sp.durationMs || undefined,
|
||||
durationMs: sp.durationMs || req.body.durationMs || undefined,
|
||||
};
|
||||
const searchArtist = artist.split(',')[0].trim();
|
||||
if (!meta.album) {
|
||||
log(jobId, '📋 Suche Album-Metadaten von iTunes…');
|
||||
const extra = await lookupItunesTrackMeta(searchArtist, title);
|
||||
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
|
||||
const extra = await tm.lookupTrackMeta(searchArtist, title);
|
||||
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
|
||||
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
|
||||
} else {
|
||||
log(jobId, `📋 Album aus Spotify: "${meta.album}"`);
|
||||
// Spotify enrichment has no genre, totalTracks, or disc info — fetch from iTunes
|
||||
if (!meta.genre || !meta.totalTracks) {
|
||||
const genreMeta = await lookupItunesTrackMeta(searchArtist, title).catch(() => ({}));
|
||||
log(jobId, `📋 Album: "${meta.album}"`);
|
||||
// Always fetch Deezer when any field is missing — durationMs is required for the yt-dlp
|
||||
// duration filter; without it any unrelated video that matches the title text gets downloaded.
|
||||
if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) {
|
||||
// Strip release-type suffixes before the Deezer lookup so "recognize - Single" → "recognize"
|
||||
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 lookupTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
|
||||
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
|
||||
if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); }
|
||||
if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); }
|
||||
if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; }
|
||||
if (!meta.discNum && genreMeta.discNum) { meta.discNum = genreMeta.discNum; }
|
||||
if (!meta.discTotal && genreMeta.discTotal) { meta.discTotal = genreMeta.discTotal; }
|
||||
if (!meta.durationMs && genreMeta.durationMs) { meta.durationMs = genreMeta.durationMs; log(jobId, `📋 Duration: ${Math.round(genreMeta.durationMs/1000)}s`); }
|
||||
if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; }
|
||||
}
|
||||
}
|
||||
const finalArtist = meta.itunesArtistName || artist;
|
||||
const finalArtist = meta.metaArtistName || artist;
|
||||
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
|
||||
if (result?.skipped) {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||
} else if (result?.notFound) {
|
||||
updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' });
|
||||
} else if (result?.failed) {
|
||||
updateJob(jobId, { status: 'error', message: 'Download fehlgeschlagen — YouTube hat den Abruf blockiert (403).' });
|
||||
} else {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
|
||||
}
|
||||
|
||||
} else if (type === 'album') {
|
||||
let tracks = [];
|
||||
if (collectionId) {
|
||||
log(jobId, '📋 Lade Track-Liste von iTunes…');
|
||||
tracks = await fetchItunesTracks(collectionId);
|
||||
// Prefer a track list supplied in the request body (Deezer albums send their tracks directly,
|
||||
// pre-mapped to the ItunesItem shape). Fall back to a Deezer lookup for legacy callers.
|
||||
let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : [];
|
||||
if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`);
|
||||
if (!tracks.length && collectionId) {
|
||||
log(jobId, '📋 Lade Track-Liste von Deezer…');
|
||||
tracks = await dz.fetchAlbumTracks(collectionId);
|
||||
}
|
||||
if (!tracks.length) {
|
||||
log(jobId, '🔍 Suche Album auf iTunes…');
|
||||
tracks = await searchItunesAlbumTracks(artist, album || title);
|
||||
log(jobId, '🔍 Suche Album auf Deezer…');
|
||||
tracks = await dz.searchAlbumTracks(artist, album || title);
|
||||
}
|
||||
if (!tracks.length) {
|
||||
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`);
|
||||
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die Global-Suche herunterladen.`);
|
||||
}
|
||||
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl });
|
||||
// Album artist: prefer the album-level collectionArtistName (identical across every track)
|
||||
// over the request's `artist`, which can be a single track's performer — e.g. the lead single
|
||||
// "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's
|
||||
// album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album.
|
||||
const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist;
|
||||
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (Deezer)`);
|
||||
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl });
|
||||
const allSkipped = dlSkipped === tracks.length;
|
||||
const someSkipped = dlSkipped > 0 && !allSkipped;
|
||||
const msg = allSkipped
|
||||
@@ -620,19 +829,17 @@ app.post('/api/download', async (req, res) => {
|
||||
|
||||
} else if (type === 'artist') {
|
||||
log(jobId, `🎤 Künstler-Download: ${artist}`);
|
||||
const artistData = await new Promise((resolve, reject) => {
|
||||
https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => {
|
||||
let d = ''; res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
|
||||
// Deezer discography: search the artist, take their real albums (not singles/EPs).
|
||||
const artistAlbums = await dz.fetchArtistAlbums(artist, 10).catch(() => []);
|
||||
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
|
||||
let done = 0;
|
||||
for (const alb of artistAlbums) {
|
||||
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
|
||||
const tracks = await dz.fetchAlbumTracks(alb.collectionId).catch(() => []);
|
||||
if (tracks.length) {
|
||||
await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
|
||||
// Same as the album path: album_artist from the album-level artist, so a collaboration
|
||||
// or soundtrack in the artist's discography isn't tagged with the searched artist.
|
||||
const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist;
|
||||
await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
|
||||
}
|
||||
done++;
|
||||
updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) });
|
||||
@@ -643,50 +850,53 @@ app.post('/api/download', async (req, res) => {
|
||||
const plTracks = req.body.tracks || [];
|
||||
if (!plTracks.length) throw new Error('Keine Tracks übergeben');
|
||||
log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`);
|
||||
let done = 0, skipped = 0;
|
||||
let done = 0, skipped = 0, notFound = 0;
|
||||
const CONC = CONFIG.maxParallel || 2;
|
||||
for (let i = 0; i < plTracks.length; i += CONC) {
|
||||
const batch = plTracks.slice(i, i + CONC);
|
||||
const batchResults = await Promise.allSettled(batch.map(async track => {
|
||||
const trackTitle = track.trackName || '';
|
||||
const trackArtist = track.artistName || artist || '';
|
||||
// If the frontend already enriched this track with iTunes data (collectionName +
|
||||
// Strip release-type suffixes before the Deezer lookup ("Song - Single" → "Song")
|
||||
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 lookupTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
|
||||
// If the frontend already enriched this track with Deezer data (collectionName +
|
||||
// trackNumber + durationMs all present), skip the re-lookup to avoid a different
|
||||
// iTunes result overriding the already-correct enriched metadata.
|
||||
// Deezer result overriding the already-correct enriched metadata.
|
||||
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
|
||||
const searchArtist = trackArtist.split(',')[0].trim();
|
||||
let itunes = {};
|
||||
let meta = {};
|
||||
if (alreadyEnriched) {
|
||||
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
|
||||
// Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those
|
||||
const genreMeta = await lookupItunesTrackMeta(searchArtist, trackTitle).catch(() => ({}));
|
||||
itunes = {
|
||||
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
|
||||
meta = {
|
||||
genre: genreMeta.genre,
|
||||
totalTracks: genreMeta.totalTracks,
|
||||
discNum: genreMeta.discNum,
|
||||
discTotal: genreMeta.discTotal,
|
||||
};
|
||||
} else {
|
||||
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
|
||||
itunes = await lookupItunesTrackMeta(searchArtist, trackTitle);
|
||||
log(jobId, `📋 Deezer: "${trackArtist} – ${trackTitle}"`);
|
||||
meta = await dz.lookupTrackMeta(searchArtist, lookupTitle);
|
||||
}
|
||||
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders.
|
||||
// Prefer the resolved name (from server lookup or frontend enrichment) for sort tags / folders.
|
||||
// trackArtist stays as rawArtist for main tags and YTM search.
|
||||
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist;
|
||||
const resolvedArtist = meta.metaArtistName || track.metaArtistName || trackArtist;
|
||||
return downloadTrack({
|
||||
jobId,
|
||||
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names
|
||||
artist: resolvedArtist, // resolved (Deezer) name → used for sort tags + folder names
|
||||
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
|
||||
albumArtist: resolvedArtist,
|
||||
title: trackTitle,
|
||||
album: itunes.album || track.collectionName || track.album || '',
|
||||
year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
|
||||
trackNum: itunes.trackNum || track.trackNumber || undefined,
|
||||
totalTracks: itunes.totalTracks || undefined,
|
||||
genre: itunes.genre || track.primaryGenreName || track.genre || undefined,
|
||||
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
|
||||
album: meta.album || track.collectionName || track.album || '',
|
||||
year: meta.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
|
||||
trackNum: meta.trackNum || track.trackNumber || undefined,
|
||||
totalTracks: meta.totalTracks || undefined,
|
||||
genre: meta.genre || track.primaryGenreName || track.genre || undefined,
|
||||
artworkUrl: meta.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
|
||||
explicit: track.trackExplicitness === 'explicit',
|
||||
durationMs: itunes.durationMs || track.durationMs || undefined,
|
||||
durationMs: meta.durationMs || track.durationMs || undefined,
|
||||
sourceUrl: track.sourceUrl || undefined,
|
||||
});
|
||||
}));
|
||||
@@ -696,10 +906,13 @@ app.post('/api/download', async (req, res) => {
|
||||
log(jobId, `⚠️ Fehler bei "${batch[j].trackName || '?'}": ${r.reason?.message || r.reason}`);
|
||||
} else if (r.value?.skipped) {
|
||||
skipped++;
|
||||
} else if (r.value?.notFound) {
|
||||
notFound++;
|
||||
log(jobId, `⚠️ Nicht eindeutig gefunden: "${batch[j].trackName || '?'}" — Direktlink nutzen`);
|
||||
} else {
|
||||
done++;
|
||||
}
|
||||
updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) });
|
||||
updateJob(jobId, { progress: Math.round(((done + skipped + notFound) / plTracks.length) * 90) });
|
||||
}
|
||||
}
|
||||
const allSkipped = skipped === plTracks.length;
|
||||
@@ -707,7 +920,7 @@ app.post('/api/download', async (req, res) => {
|
||||
status: 'done', progress: 100, skipped: allSkipped,
|
||||
message: allSkipped
|
||||
? `Alle ${skipped} Tracks bereits vorhanden`
|
||||
: `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}`,
|
||||
: `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}${notFound ? `, ${notFound} nicht gefunden` : ''}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -764,7 +977,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
|
||||
let done = 0, errors = 0;
|
||||
const errorDetails = [];
|
||||
const safeArtistDir = sanitize(artistSort.split(/[,;&]/)[0].trim());
|
||||
const safeArtistDir = sanitize(artistKanji.split(/[,;&]/)[0].trim());
|
||||
|
||||
for (const ndSong of songs) {
|
||||
try {
|
||||
@@ -819,7 +1032,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
// Split on first " - " or " – ": if 2+ segments, first is old artist, rest is title
|
||||
const segments = afterPrefix.split(/\s+[-–—]\s+/);
|
||||
const titlePart = segments.length >= 2 ? segments.slice(1).join(' - ') : afterPrefix;
|
||||
const safeArtistFile = sanitize(artistSort.split(/[,;&]/)[0].trim());
|
||||
const safeArtistFile = sanitize(artistKanji.split(/[,;&]/)[0].trim());
|
||||
const safeTitlePart = sanitize(titlePart);
|
||||
const newBasename = `${trackPrefix}${safeArtistFile} - ${safeTitlePart}${fileExt}`;
|
||||
|
||||
@@ -976,15 +1189,20 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
|
||||
const trackNum = release?.media?.[0]?.['track-offset'] != null
|
||||
? (release.media[0]['track-offset'] + 1) : undefined;
|
||||
const totalTracks = release?.media?.[0]?.['track-count'] || undefined;
|
||||
const discNum = release?.media?.[0]?.position || undefined;
|
||||
const coverUrl = release?.id ? `https://coverartarchive.org/release/${release.id}/front` : null;
|
||||
// album-artist from release artist-credit if present, else fall back to recording artist
|
||||
const mbAlbumArtist = (release?.['artist-credit'] || []).map(c => c.name || c.artist?.name).filter(Boolean).join('; ') || mbArtist;
|
||||
res.json({
|
||||
found: true,
|
||||
title: rec.title || title,
|
||||
artist: mbArtist || artist,
|
||||
albumArtist: mbAlbumArtist || undefined,
|
||||
album: release?.title || '',
|
||||
year: year ? parseInt(year, 10) : undefined,
|
||||
trackNum: trackNum,
|
||||
totalTracks: totalTracks,
|
||||
discNum: discNum,
|
||||
genre: genre,
|
||||
coverUrl: coverUrl,
|
||||
mbid: rec.id,
|
||||
@@ -1152,7 +1370,11 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
const found = rebased
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(rebasedDir || originalDir, fileName)
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|
||||
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
|
||||
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
|
||||
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|
||||
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
|
||||
if (found) { filePath = found; }
|
||||
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
|
||||
}
|
||||
@@ -1235,9 +1457,10 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
let currentFile = filePath;
|
||||
|
||||
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
||||
// Use original names (may be Japanese); romaji goes only into sort tags.
|
||||
if (title) {
|
||||
const safeTitle = sanitize(romajiTitle || title);
|
||||
const fileArtist = (romajiArtist || ndSong?.artist || '').replace(/;/g, ',');
|
||||
const safeTitle = sanitize(title);
|
||||
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
|
||||
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
||||
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
||||
@@ -1251,8 +1474,8 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
// 2. Move to new artist/album folder when albumArtist or album changed
|
||||
const oldAlbumDir = path.dirname(currentFile);
|
||||
const oldArtistDir = path.dirname(oldAlbumDir);
|
||||
const newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir);
|
||||
const newArtistName = (albumArtistSort || romajiAlbumArtist) ? sanitize((albumArtistSort || romajiAlbumArtist).split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
|
||||
const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir);
|
||||
const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
|
||||
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
|
||||
|
||||
if (newDir !== oldAlbumDir) {
|
||||
@@ -1273,9 +1496,9 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) { console.error('[edit-metadata] move:', e.message); }
|
||||
} else if (romajiAlbum && path.basename(oldAlbumDir) !== sanitize(romajiAlbum)) {
|
||||
} else if (album && path.basename(oldAlbumDir) !== sanitize(album)) {
|
||||
// Only album name changed (no artist move) — rename album folder in place
|
||||
const renamedDir = path.join(oldArtistDir, sanitize(romajiAlbum));
|
||||
const renamedDir = path.join(oldArtistDir, sanitize(album));
|
||||
if (!fs.existsSync(renamedDir)) {
|
||||
try { fs.renameSync(oldAlbumDir, renamedDir); }
|
||||
catch (e) { console.error('[edit-metadata] folder rename:', e.message); }
|
||||
@@ -1333,6 +1556,7 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` });
|
||||
}
|
||||
|
||||
const ndPath = filePath; // path exactly as Navidrome reported it (for diagnostics)
|
||||
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const fileName = path.basename(filePath);
|
||||
@@ -1342,7 +1566,9 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
|| _findFileInDir(path.dirname(filePath), fileName)
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
if (found) filePath = found;
|
||||
else return res.status(404).json({ error: 'File not found on disk' });
|
||||
else return res.status(404).json({
|
||||
error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Safety: file must be inside the music root and must be an audio file
|
||||
@@ -1428,10 +1654,6 @@ app.listen(CONFIG.port, () => {
|
||||
console.log(`\n🎵 NovaSonic Backend running on port ${CONFIG.port}`);
|
||||
console.log(` Music folder : ${CONFIG.musicPath}`);
|
||||
console.log(` Audio format : ${CONFIG.audioFmt} (quality ${CONFIG.audioQuality})`);
|
||||
console.log(` Download backend : ${CONFIG.downloadBackend}`);
|
||||
if (CONFIG.downloadBackend !== 'ytdlp') {
|
||||
console.log(` Squid quality : ${CONFIG.squidQuality}`);
|
||||
}
|
||||
console.log(` yt-dlp : ${ytdlpVersion}`);
|
||||
console.log('');
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "novasonic-backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"kuromoji": "^0.1.2"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
// Unit tests for the Deezer metadata mapping/matching (backend/lib/deezer.js).
|
||||
// Pure logic only — no network. Run: `node --test` from backend/ (or `npm test`).
|
||||
// A live smoke test against api.deezer.com is included but only runs with RUN_LIVE=1.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const dz = require('../lib/deezer');
|
||||
|
||||
// ── Fixtures: the exact Deezer shapes the mappers consume ─────────────────────
|
||||
const ALBUM = {
|
||||
id: 302127,
|
||||
title: 'Discovery',
|
||||
cover_xl: 'https://img/cover_xl.jpg',
|
||||
cover_big: 'https://img/cover_big.jpg',
|
||||
release_date: '2001-03-07',
|
||||
nb_tracks: 14,
|
||||
record_type: 'album',
|
||||
genres: { data: [{ id: 113, name: 'Dance' }] },
|
||||
artist: { id: 27, name: 'Daft Punk' },
|
||||
};
|
||||
const TRACK = {
|
||||
id: 3135556,
|
||||
title: 'Harder, Better, Faster, Stronger',
|
||||
duration: 224,
|
||||
explicit_lyrics: false,
|
||||
preview: 'https://img/preview.mp3',
|
||||
artist: { id: 27, name: 'Daft Punk' },
|
||||
};
|
||||
|
||||
test('mapAlbumTrack: numbers by album order and inherits album-level fields', () => {
|
||||
const item = dz.mapAlbumTrack(TRACK, ALBUM, 3); // 4th track
|
||||
assert.equal(item.wrapperType, 'track');
|
||||
assert.equal(item.kind, undefined); // shape has no kind here; downloadAlbumTracks doesn't read it
|
||||
assert.equal(item.trackNumber, 4); // index + 1 (Deezer album tracks carry no track_position)
|
||||
assert.equal(item.trackName, 'Harder, Better, Faster, Stronger');
|
||||
assert.equal(item.collectionName, 'Discovery');
|
||||
assert.equal(item.collectionArtistName, 'Daft Punk');
|
||||
assert.equal(item.primaryGenreName, 'Dance');
|
||||
assert.equal(item.releaseDate, '2001-03-07');
|
||||
assert.equal(item.trackCount, 14);
|
||||
assert.equal(item.trackTimeMillis, 224000); // seconds → ms
|
||||
assert.equal(item.trackExplicitness, 'notExplicit');
|
||||
assert.equal(item.artworkUrl100, 'https://img/cover_xl.jpg');
|
||||
});
|
||||
|
||||
test('mapAlbumTrack: per-track artist wins over the album artist (mixed-artist albums)', () => {
|
||||
const feat = { ...TRACK, artist: { id: 99, name: 'MeloMance' } };
|
||||
const item = dz.mapAlbumTrack(feat, ALBUM, 0);
|
||||
assert.equal(item.artistName, 'MeloMance'); // drives the per-track YTM search
|
||||
assert.equal(item.collectionArtistName, 'Daft Punk'); // album_artist stays stable → no album split
|
||||
});
|
||||
|
||||
test('mapAlbumTrack: explicit flag maps through', () => {
|
||||
const item = dz.mapAlbumTrack({ ...TRACK, explicit_lyrics: true }, ALBUM, 0);
|
||||
assert.equal(item.trackExplicitness, 'explicit');
|
||||
});
|
||||
|
||||
test('pickTrackMatch: strict match on artist + title', () => {
|
||||
const results = [
|
||||
{ title: 'Some Other Song', artist: { name: 'Daft Punk' } },
|
||||
{ title: 'Harder, Better, Faster, Stronger', artist: { name: 'Daft Punk' } },
|
||||
];
|
||||
const m = dz.pickTrackMatch(results, 'Daft Punk', 'Harder, Better, Faster, Stronger');
|
||||
assert.equal(m, results[1]);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
|
||||
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
|
||||
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
|
||||
const results = [
|
||||
{ title: 'Memories', artist: { name: 'Rab Noakes' } },
|
||||
{ title: 'Memories', artist: { name: 'Maroon 5' } },
|
||||
];
|
||||
assert.equal(dz.pickTrackMatch(results, '☆NoAki☆', 'Memories'), null);
|
||||
assert.equal(dz.pickTrackMatch(results, 'Some Cover Act', 'Levitating'), null);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: loose artist match — one name contained in the other', () => {
|
||||
const results = [{ title: 'Flower Dance', artist: { name: 'DJ Okawari' } }];
|
||||
const m = dz.pickTrackMatch(results, 'Okawari', 'Flower Dance');
|
||||
assert.equal(m, results[0]); // "okawari" ⊂ "djokawari" → accepted
|
||||
});
|
||||
|
||||
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
|
||||
const results = [{ title: '残酷な天使のテーゼ', artist: { name: 'Someone Else' } }];
|
||||
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
|
||||
assert.equal(m, null); // JP title + wrong artist must not match a same-titled cover
|
||||
});
|
||||
|
||||
test('pickTrackMatch: Japanese title DOES match when the artist matches', () => {
|
||||
const results = [{ title: '残酷な天使のテーゼ', artist: { name: '高橋洋子' } }];
|
||||
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
|
||||
assert.equal(m, results[0]);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: leading aesthetic punctuation is ignored on the artist', () => {
|
||||
const results = [{ title: 'nostalgia', artist: { name: '.diedlonely' } }];
|
||||
const m = dz.pickTrackMatch(results, '.diedlonely', 'nostalgia');
|
||||
assert.equal(m, results[0]);
|
||||
});
|
||||
|
||||
test('pickTrackMatch: empty / no match returns null', () => {
|
||||
assert.equal(dz.pickTrackMatch([], 'x', 'y'), null);
|
||||
assert.equal(dz.pickTrackMatch([{ title: 'A', artist: { name: 'B' } }], 'C', 'D'), null);
|
||||
});
|
||||
|
||||
test('normalizeTitle: unifies variant suffixes and punctuation', () => {
|
||||
assert.equal(dz.normalizeTitle("don't rush - slowed"), dz.normalizeTitle("don't rush (slowed)"));
|
||||
assert.equal(dz.normalizeTitle('Mr. Lawrence'), dz.normalizeTitle('Mr Lawrence'));
|
||||
});
|
||||
|
||||
// ── Live smoke test (opt-in): confirms the real Deezer shape still maps ────────
|
||||
test('LIVE: lookupTrackMeta returns enriched fields', { skip: process.env.RUN_LIVE !== '1' }, async () => {
|
||||
const meta = await dz.lookupTrackMeta('Daft Punk', 'Harder, Better, Faster, Stronger');
|
||||
assert.ok(meta.album, 'album present');
|
||||
assert.ok(meta.genre, 'genre present');
|
||||
assert.ok(meta.year, 'year present');
|
||||
assert.ok(meta.durationMs > 0, 'duration present');
|
||||
});
|
||||
@@ -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
-6
@@ -10,10 +10,11 @@ services:
|
||||
environment:
|
||||
- NAVIDROME_URL=${NAVIDROME_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) ─────────────────────────────────────────
|
||||
novasonic-backend:
|
||||
image: node:20-slim
|
||||
image: node:22-slim
|
||||
container_name: novasonic-backend
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
@@ -30,17 +31,45 @@ services:
|
||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-auto}
|
||||
- SQUID_QUALITY=${SQUID_QUALITY:-mp3}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||
- 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: >
|
||||
sh -c "
|
||||
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git && rm -rf /var/lib/apt/lists/* &&
|
||||
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp &&
|
||||
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && rm -rf /var/lib/apt/lists/* &&
|
||||
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' &&
|
||||
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:
|
||||
novasonic-data: # persistent storage for romaji overrides and app state
|
||||
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user