fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s
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:
+1
-3
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTracks()
|
likes.observeLikedTrackIds()
|
||||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ class LikesRepository @Inject constructor(
|
|||||||
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
|
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
|
||||||
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
|
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. */
|
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||||
suspend fun likedTrackIds(): Set<String> =
|
suspend fun likedTrackIds(): Set<String> =
|
||||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||||
|
|||||||
+10
-2
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||||
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -241,7 +242,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
|||||||
isPublic = isPublic,
|
isPublic = isPublic,
|
||||||
systemVariant = systemVariant,
|
systemVariant = systemVariant,
|
||||||
trackCount = trackCount,
|
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 =
|
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
|
||||||
@@ -277,7 +282,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
|
|||||||
isPublic = isPublic,
|
isPublic = isPublic,
|
||||||
systemVariant = systemVariant,
|
systemVariant = systemVariant,
|
||||||
trackCount = trackCount,
|
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,
|
ownerUsername = ownerUsername,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTracks()
|
likes.observeLikedTrackIds()
|
||||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
|||||||
Reference in New Issue
Block a user