feat(android): Phase 17a — PlaylistDetail per-track LikeButton

Closes the per-track-likes gap on PlaylistDetail (Album + Artist
details already had it via Phase 14). Same VM-owned-state pattern:
LikesRepository observeLikedTracks → mutableSet<String> Flow,
toggleLikeTrack via the optimistic-write + MutationQueue path.

Modified:
  - playlists/ui/PlaylistDetailScreen.kt — VM gets LikesRepository
    injection + `likedTrackIds: StateFlow<Set<String>>` +
    `toggleLikeTrack(trackId)`. PlaylistDetailBody threads the set
    + onToggleTrackLike down to each row. TrackRow renders LikeButton
    only when the upstream track is still available (greyed-out
    rows for removed tracks omit the heart entirely — can't like
    something that no longer exists).
    Row vertical padding tightened 10dp → 8dp to match the album
    track-row sizing now that the heart icon is present.

Cross-restart user persistence is the next commit within this phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:31:06 -04:00
parent f0278ed2bf
commit 16b3a1e9e2
@@ -57,16 +57,23 @@ import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel
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
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
sealed interface PlaylistDetailUiState {
@@ -80,6 +87,7 @@ sealed interface PlaylistDetailUiState {
@HiltViewModel
class PlaylistDetailViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val likes: LikesRepository,
private val player: PlayerController,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -90,10 +98,26 @@ class PlaylistDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
init {
refresh()
}
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
fun refresh() {
viewModelScope.launch {
try {
@@ -146,14 +170,21 @@ fun PlaylistDetailScreen(
when (val s = state) {
PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> PlaylistDetailBody(
detail = s.detail,
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onTrackClick = { id -> viewModel.play(s.detail.tracks, startTrackId = id) },
)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
PlaylistDetailBody(
detail = s.detail,
likedTrackIds = likedTrackIds,
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onTrackClick = { id ->
viewModel.play(s.detail.tracks, startTrackId = id)
},
onToggleTrackLike = viewModel::toggleLikeTrack,
)
}
}
}
}
@@ -169,9 +200,11 @@ private fun currentTitle(state: PlaylistDetailUiState): String = when (state) {
@Composable
private fun PlaylistDetailBody(
detail: PlaylistDetailRef,
likedTrackIds: Set<String>,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit,
onToggleTrackLike: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -180,7 +213,12 @@ private fun PlaylistDetailBody(
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) }
item { HorizontalDivider() }
items(items = detail.tracks, key = { it.position }) { row ->
TrackRow(row = row, onClick = { row.trackId?.let(onTrackClick) })
TrackRow(
row = row,
liked = row.trackId != null && row.trackId in likedTrackIds,
onClick = { row.trackId?.let(onTrackClick) },
onToggleLike = { row.trackId?.let(onToggleTrackLike) },
)
HorizontalDivider()
}
if (detail.tracks.isEmpty()) {
@@ -283,14 +321,19 @@ private fun PlaylistCover(playlist: PlaylistRef) {
}
@Composable
private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) {
private fun TrackRow(
row: PlaylistTrackRef,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -321,6 +364,9 @@ private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
modifier = Modifier.padding(start = 12.dp),
)
if (enabled) {
LikeButton(liked = liked, onToggle = onToggleLike)
}
}
}