feat: M4b inbound ListenBrainz similarity ingest #27

Merged
bvandeusen merged 262 commits from dev into main 2026-04-28 21:22:38 -04:00
Showing only changes of commit 16b3a1e9e2 - Show all commits
@@ -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)
}
} }
} }