From 3df5e5cb3c138a502d34db29d22e019142423b11 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 13:11:55 -0400 Subject: [PATCH] fix(android): playlist like state + playlist cover URL 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> 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//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. --- .../minstrel/library/ui/AlbumDetailViewModel.kt | 4 +--- .../minstrel/likes/data/LikesRepository.kt | 14 ++++++++++++++ .../minstrel/playlists/data/PlaylistsRepository.kt | 12 ++++++++++-- .../minstrel/playlists/ui/PlaylistDetailScreen.kt | 4 +--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt index 52926a69..dfb36fa5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -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> = - likes.observeLikedTracks() - .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + likes.observeLikedTrackIds() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt index c876c7b6..f5e009f5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -110,6 +110,20 @@ class LikesRepository @Inject constructor( fun observeIsLiked(entityType: String, entityId: String): Flow = 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> = + 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 = likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index 3f744ef2..02bf1c26 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -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//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//cover and Coil needs the placeholder + // host for BaseUrlInterceptor to rewrite to the live server. + coverUrl = resolveServerUrl(coverUrl) ?: "", ownerUsername = ownerUsername, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index db8c3529..1df819ef 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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 = regeneratedChannel.receiveAsFlow() val likedTrackIds: StateFlow> = - likes.observeLikedTracks() - .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + likes.observeLikedTrackIds() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),