From d11de8a69e770c43ea38cc241b060676f951d617 Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 21 Aug 2026 23:10:28 +0200 Subject: [PATCH] feat(android): importer 30s preview + partial-album detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Importer: each resolved track gets a play/stop button that plays its Deezer/Spotify preview via the app-level preview bar (pauses library playback, highlights the row). Parity with the web importer. - Artist "Unvollständig" now works: Deezer's /artist/{id}/albums omits nb_tracks, so owned albums never registered as partial. The artist VM now resolves the real total for matched albums via a per-album lookup (like web), so the partial section + downloaded/missing split populate. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++++ .../novasonic/app/ui/screen/ImporterScreen.kt | 29 +++++++++++-- .../app/viewmodel/ArtistViewModel.kt | 31 ++++++++++++++ .../app/viewmodel/ExploreViewModel.kt | 41 ++++++++++++++++++- .../app/viewmodel/ImporterViewModel.kt | 28 ++++++++++++- 5 files changed, 132 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 790491c..a5766eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,9 @@ like Android), concert events become **reliable across web/Android**, and severa - **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. ### Fixed - **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently @@ -94,6 +97,12 @@ like Android), concert events become **reliable across web/Android**, and severa - **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. +- **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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt index 68cb18c..632d550 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt @@ -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, + ) + } + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index 279b17b..8455e36 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -144,6 +144,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV pinnedRelated = relatedD.await(), itunesLoading = false, ) } + enrichPartialCounts() return } // Not pinned → clear any stale pinned-related from a previous pin. @@ -180,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("[^a-z0-9]"), "") + 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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index e4739d5..31fa722 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -188,19 +188,56 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) { val iAlbumsD: Deferred> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() } val iTracksD: Deferred> = 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, iAlbums: List, + ): List { + if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums + fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "") + var changed = false + val out = coroutineScope { + iAlbums.map { ia -> + val cid = ia.collectionId + val known = (ia.trackCount ?: 0) > 0 + val matched = ndAlbums.any { a -> + val n = norm(a.name); val i = norm(ia.collectionName) + n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i)) + } + if (!known && matched && cid != null) async { + val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0) + if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia + } else async { ia } + }.map { it.await() } + } + return if (changed) out else iAlbums + } + fun closeArtistDetail() { _state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt index 504155e..0c2c94c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.novasonic.app.api.ImporterClient import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.api.model.ImportResolveResponse +import com.novasonic.app.api.model.ImportTrack import com.novasonic.app.app import com.novasonic.app.data.NotificationStore import kotlinx.coroutines.delay @@ -26,11 +27,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 repo = app.app.repository + private val player = app.app.playerController + private val preview = app.app.previewController private val _state = MutableStateFlow(ImporterUiState()) val state: StateFlow = _state @@ -41,8 +46,29 @@ 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( link = v,