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.models.TrackRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.player.PlayerController 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.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
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.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
sealed interface PlaylistDetailUiState { sealed interface PlaylistDetailUiState {
@@ -80,6 +87,7 @@ sealed interface PlaylistDetailUiState {
@HiltViewModel @HiltViewModel
class PlaylistDetailViewModel @Inject constructor( class PlaylistDetailViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -90,10 +98,26 @@ class PlaylistDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading) private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow() 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 { init {
refresh() refresh()
} }
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -146,14 +170,21 @@ fun PlaylistDetailScreen(
when (val s = state) { when (val s = state) {
PlaylistDetailUiState.Loading -> LoadingCentered() PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> PlaylistDetailBody( is PlaylistDetailUiState.Success -> {
detail = s.detail, val likedTrackIds by viewModel.likedTrackIds.collectAsState()
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) }, PlaylistDetailBody(
onShuffleAll = { detail = s.detail,
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null) likedTrackIds = likedTrackIds,
}, onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onTrackClick = { id -> viewModel.play(s.detail.tracks, startTrackId = id) }, 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 @Composable
private fun PlaylistDetailBody( private fun PlaylistDetailBody(
detail: PlaylistDetailRef, detail: PlaylistDetailRef,
likedTrackIds: Set<String>,
onPlayAll: () -> Unit, onPlayAll: () -> Unit,
onShuffleAll: () -> Unit, onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit, onTrackClick: (String) -> Unit,
onToggleTrackLike: (String) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -180,7 +213,12 @@ private fun PlaylistDetailBody(
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) } item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) }
item { HorizontalDivider() } item { HorizontalDivider() }
items(items = detail.tracks, key = { it.position }) { row -> 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() HorizontalDivider()
} }
if (detail.tracks.isEmpty()) { if (detail.tracks.isEmpty()) {
@@ -283,14 +321,19 @@ private fun PlaylistCover(playlist: PlaylistRef) {
} }
@Composable @Composable
private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) { private fun TrackRow(
row: PlaylistTrackRef,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
val enabled = row.isAvailable val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -321,6 +364,9 @@ private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha),
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
) )
if (enabled) {
LikeButton(liked = liked, onToggle = onToggleLike)
}
} }
} }