fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s

Two independent bugs surfaced together:

Bug 1: like button on tracks in playlist/album detail screens didn't
reflect actual liked state. LikesRepository.observeLikedTracks() does
a mapNotNull join against trackDao - a liked track whose row isn't in
the local cache yet (e.g. liked via web/notification, cache not
sync'd) gets DROPPED from the returned list. PlaylistDetailViewModel
+ AlbumDetailViewModel both used that as their like-set source, so
those rows showed as not-liked.

Adds LikesRepository.observeLikedTrackIds(): Flow<Set<String>> that
hits the DAO directly via observeLikedIdsOfType - no trackDao join,
no drops. The two ViewModels switch to it. LikedTab continues to use
observeLikedTracks because it needs the full TrackRef to render.

Bug 2: playlist cover art didn't render on the playlist detail
header. Server's derivePlaylistView returns CoverURL as the relative
path "/api/playlists/<id>/cover". PlaylistsRepository's two domain
mappers (CachedPlaylistEntity.toDomain + PlaylistDetailWire
.toPlaylistRef) stored it verbatim - Coil's AsyncImage can't fetch a
relative URL with no base, so the image silently failed.

Wraps the coverPath/coverUrl through resolveServerUrl so the
placeholder.invalid host triggers BaseUrlInterceptor's live-server
rewrite, same idiom every other cover surface (album / artist / track
/ playlist track rows) already uses.

System-playlist 24h refresh investigation pending - need to know how
you verified (server logs, DB state, or client-visible content)
before I can dig into the right layer.
This commit is contained in:
2026-06-03 13:11:55 -04:00
parent 448c9f2e74
commit 3df5e5cb3c
4 changed files with 26 additions and 8 deletions
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
)
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -110,6 +110,20 @@ class LikesRepository @Inject constructor(
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
/**
* Reactive set of liked track ids. Use this when the caller only
* needs "is X in liked set", NOT when it needs to render the liked
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
* against `trackDao` and drops any liked id whose track isn't in
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
* misses cross-device likes whose track row hasn't cached.
*
* Used by playlist/album detail screens to color a row's like
* button independent of whether the track is in the local library.
*/
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
@@ -241,7 +242,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverPath ?: "",
// Server returns a relative cover path like "/api/playlists/<id>/cover";
// wrap through the placeholder host so BaseUrlInterceptor + the shared
// Coil/OkHttpClient resolve it against the live server. Without this
// wrap AsyncImage silently fails on the relative URL.
coverUrl = resolveServerUrl(coverPath) ?: "",
)
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
@@ -277,7 +282,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverUrl,
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
// host for BaseUrlInterceptor to rewrite to the live server.
coverUrl = resolveServerUrl(coverUrl) ?: "",
ownerUsername = ownerUsername,
)
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),