feat(android): importer 30s preview + partial-album detection
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b584159568
commit
d11de8a69e
5 files changed
+132
-6
No files matched your search
@@ -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
|
- **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
|
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.
|
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
|
### Fixed
|
||||||
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
|
- **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"`,
|
- **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 →
|
dropping EPs and singles that Deezer (and the web app) show. Now returns all, sorted album → EP →
|
||||||
single.
|
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
|
- **"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.
|
in the artist hero's corner instead of a full-width labelled button.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -205,7 +207,12 @@ fun ImporterScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemsIndexed(r.tracks) { idx, track ->
|
itemsIndexed(r.tracks) { idx, track ->
|
||||||
ImportTrackRow(idx + 1, track)
|
ImportTrackRow(
|
||||||
|
num = idx + 1,
|
||||||
|
track = track,
|
||||||
|
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
|
||||||
|
onPreview = { vm.togglePreview(track) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +265,12 @@ private fun JobProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
private fun ImportTrackRow(
|
||||||
|
num: Int,
|
||||||
|
track: ImportTrack,
|
||||||
|
isPreviewing: Boolean,
|
||||||
|
onPreview: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
@@ -272,11 +284,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (!track.artistName.isNullOrBlank()) {
|
if (!track.artistName.isNullOrBlank()) {
|
||||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 30s preview — only when the source provided a clip
|
||||||
|
if (track.previewUrl != null) {
|
||||||
|
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||||
|
if (isPreviewing) "Stop" else "Preview",
|
||||||
|
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
pinnedRelated = relatedD.await(), itunesLoading = false,
|
pinnedRelated = relatedD.await(), itunesLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
enrichPartialCounts()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Not pinned → clear any stale pinned-related from a previous pin.
|
// Not pinned → clear any stale pinned-related from a previous pin.
|
||||||
@@ -180,11 +181,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
itunesLoading = false,
|
itunesLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
enrichPartialCounts()
|
||||||
} else {
|
} else {
|
||||||
_state.value = _state.value.copy(itunesLoading = false)
|
_state.value = _state.value.copy(itunesLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
|
||||||
|
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
|
||||||
|
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
|
||||||
|
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
|
||||||
|
* a couple of calls, not the whole discography.
|
||||||
|
*/
|
||||||
|
private suspend fun enrichPartialCounts() {
|
||||||
|
val ndAlbums = _state.value.artist?.album ?: return
|
||||||
|
val iAlbums = _state.value.iAlbums
|
||||||
|
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
|
||||||
|
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^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) ──────────────────────────────
|
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||||
|
|
||||||
fun loadSubAlbum(album: ItunesItem) {
|
fun loadSubAlbum(album: ItunesItem) {
|
||||||
|
|||||||
@@ -188,19 +188,56 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||||
|
|
||||||
|
val ndAlbums = ndAlbumsD.await()
|
||||||
|
val iAlbums = iAlbumsD.await()
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
artistDetail = ItunesArtistState(
|
artistDetail = ItunesArtistState(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
ndAlbums = ndAlbumsD.await(),
|
ndAlbums = ndAlbums,
|
||||||
iAlbums = iAlbumsD.await(),
|
iAlbums = iAlbums,
|
||||||
iTracks = iTracksD.await(),
|
iTracks = iTracksD.await(),
|
||||||
topSongs = topSongsD.await(),
|
topSongs = topSongsD.await(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
|
||||||
|
// Resolve the real total for matched albums (like web) so partials are detected.
|
||||||
|
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
|
||||||
|
if (enriched !== iAlbums) {
|
||||||
|
_state.value.artistDetail?.let { d ->
|
||||||
|
if (d.artist.artistId == artist.artistId) {
|
||||||
|
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
|
||||||
|
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
|
||||||
|
private suspend fun enrichPartialCounts(
|
||||||
|
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
|
||||||
|
): List<ItunesItem> {
|
||||||
|
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
|
||||||
|
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^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() {
|
fun closeArtistDetail() {
|
||||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
import com.novasonic.app.api.model.ImportResolveResponse
|
import com.novasonic.app.api.model.ImportResolveResponse
|
||||||
|
import com.novasonic.app.api.model.ImportTrack
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.NotificationStore
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -26,11 +27,15 @@ data class ImporterUiState(
|
|||||||
val jobMessage: String? = null,
|
val jobMessage: String? = null,
|
||||||
val jobCompleted: Int = 0,
|
val jobCompleted: Int = 0,
|
||||||
val jobTotal: Int = 0,
|
val jobTotal: Int = 0,
|
||||||
|
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||||
|
val previewingId: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ImporterUiState())
|
private val _state = MutableStateFlow(ImporterUiState())
|
||||||
val state: StateFlow<ImporterUiState> = _state
|
val state: StateFlow<ImporterUiState> = _state
|
||||||
@@ -41,8 +46,29 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mirror the app-level preview so the track rows show which clip is playing.
|
||||||
|
viewModelScope.launch {
|
||||||
|
preview.state.collect { ps ->
|
||||||
|
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||||
|
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
|
||||||
|
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
|
||||||
|
|
||||||
|
fun togglePreview(track: ImportTrack) {
|
||||||
|
val url = track.previewUrl ?: return
|
||||||
|
player.pause() // don't overlap the 30s clip with library playback
|
||||||
|
preview.toggle(
|
||||||
|
previewId(track), url,
|
||||||
|
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPreview() = preview.stop()
|
||||||
|
|
||||||
fun onLink(v: String) {
|
fun onLink(v: String) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
link = v,
|
link = v,
|
||||||
|
|||||||
Reference in new issue
Block a user