refactor: streamline PlayerQueueContent and ViewModel for better state management

This commit is contained in:
Kingkor Roy Tirtho 2026-08-14 18:35:42 +06:00
parent 870195bdfd
commit 19c1d0ec35
3 changed files with 160 additions and 158 deletions

View File

@ -30,6 +30,7 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## UI component patterns ## UI component patterns
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. - Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain.

View File

@ -19,9 +19,6 @@ package dev.krtirtho.spotube.modules.shell.player_queue
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -29,7 +26,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -44,127 +40,53 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger
import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.base.highlight
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
private data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String,
val durationMs: Long,
val isCurrent: Boolean,
val imageUrl: String?,
val originalIndex: Int,
)
@Composable @Composable
fun PlayerQueueContent( fun PlayerQueueContent(
viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(), viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val logger = rememberLogger("PlayerQueueContent") val state by viewModel.queueContentUiState.collectAsState()
val queueContentUiState by viewModel.queueContentUiState.collectAsState() val displayItems = state.displayItems
val queue = queueContentUiState.queue val filterQuery = state.filterQuery
val currentQueueEntry = queueContentUiState.currentQueueEntry val isFiltered = state.isFiltered
val filterQuery = queueContentUiState.filterQuery
val normalizedFilter = remember(filterQuery) { filterQuery.trim().lowercase() }
val currentIndex = remember(queue, currentQueueEntry) {
val current = currentQueueEntry ?: return@remember -1
queue.indexOfFirst { entry -> entry.matchesCurrent(current) }
}
val sourceItems = remember(queue, currentIndex) {
queue.mapIndexed { index, entry ->
val (title, subtitle, durationMs, imageUrl) = entry.toQueueDisplayData()
QueueItemUi(
id = "${entry.url}@$index",
title = title,
subtitle = subtitle,
durationMs = durationMs,
isCurrent = index == currentIndex,
imageUrl = imageUrl,
originalIndex = index,
)
}
}
val isFiltered = normalizedFilter.isNotBlank()
val displayList = remember { mutableStateListOf<QueueItemUi>() }
var moveParams by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var queueVersion by remember { mutableIntStateOf(0) }
LaunchedEffect(sourceItems, normalizedFilter) {
queueVersion++
displayList.clear()
val filtered = if (isFiltered) {
sourceItems.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
sourceItems
}
displayList.addAll(filtered)
}
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val reorderableLazyListState = val reorderableLazyListState = rememberReorderableLazyListState(
rememberReorderableLazyListState( lazyListState,
lazyListState, onMove = { from, to ->
onMove = { from, to -> if (isFiltered) return@rememberReorderableLazyListState
if (isFiltered) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index)
val item = displayList.removeAt(from.index) },
displayList.add(to.index, item) )
logger.i { "Moved item from ${from.index} to ${to.index}" }
moveParams = item.originalIndex to to.index
},
)
fun finalizeReorder() {
moveParams?.let { (fromOriginal, toDisplay) ->
logger.i { "Finalizing move from $fromOriginal to $toDisplay (version $queueVersion)" }
viewModel.moveQueueItem(fromOriginal, toDisplay)
}
moveParams = null
}
Surface(modifier = modifier) { Surface(modifier = modifier) {
Column( Column(
@ -204,7 +126,7 @@ fun PlayerQueueContent(
} }
} }
if (displayList.isEmpty()) { if (displayItems.isEmpty()) {
Text( Text(
text = "No queue entries", text = "No queue entries",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -217,7 +139,7 @@ fun PlayerQueueContent(
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(bottom = 8.dp), contentPadding = PaddingValues(bottom = 8.dp),
) { ) {
items(displayList, key = { item -> item.id }) { item -> items(displayItems, key = { it.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow( QueueItemRow(
@ -225,7 +147,8 @@ fun PlayerQueueContent(
reorderScope = if (isFiltered) null else this, reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStopped = ::finalizeReorder, onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
) )
} }
} }
@ -242,6 +165,7 @@ private fun QueueItemRow(
reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit, onDragStopped: () -> Unit,
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -263,7 +187,12 @@ private fun QueueItemRow(
.size(24.dp) .size(24.dp)
.then( .then(
if (reorderScope != null) { if (reorderScope != null) {
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) } with(reorderScope) {
Modifier.draggableHandle(
onDragStarted = { onDragStarted() },
onDragStopped = onDragStopped,
)
}
} else { } else {
Modifier Modifier
}, },
@ -319,7 +248,7 @@ private fun QueueItemRow(
}, },
trailing = { trailing = {
Text( Text(
text = item.durationMs.toDurationString(), text = item.durationLabel,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@ -356,53 +285,3 @@ private fun QueueItemRow(
} }
) )
} }
private fun QueueEntry.toQueueDisplayData(): Tuple4<String, String, Long, String?> {
return when (this) {
is QueueEntry.StreamingTrack -> {
val imageUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
Tuple4(
track.title,
track.artists.joinToString(", ") { it.name },
track.durationMs,
imageUrl,
)
}
is QueueEntry.LocalTrack -> Tuple4(
name,
artists.joinToString(", "),
duration,
null,
)
}
}
private data class Tuple4<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun Long.toDurationString(): String {
val totalSeconds = (this / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View File

@ -30,10 +30,20 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class QueueItemUi(
val id: String,
val title: String,
val subtitle: String,
val durationLabel: String,
val isCurrent: Boolean,
val imageUrl: String?,
val originalIndex: Int,
)
data class QueueContentUiState( data class QueueContentUiState(
val filterQuery: String = "", val filterQuery: String = "",
val queue: List<QueueEntry> = emptyList(), val displayItems: List<QueueItemUi> = emptyList(),
val currentQueueEntry: QueueEntry? = null, val isFiltered: Boolean = false,
) )
class PlayerQueueContentViewModel( class PlayerQueueContentViewModel(
@ -41,18 +51,79 @@ class PlayerQueueContentViewModel(
) : ViewModel() { ) : ViewModel() {
private val queueVisibilityFlow = MutableStateFlow(false) private val queueVisibilityFlow = MutableStateFlow(false)
private val queueFilterFlow = MutableStateFlow("") private val queueFilterFlow = MutableStateFlow("")
private val reorderBuffer = MutableStateFlow<List<QueueItemUi>?>(null)
private var moveFromOriginal: Int? = null
private var moveToDisplay: Int? = null
val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow() val isQueueVisible: StateFlow<Boolean> = queueVisibilityFlow.asStateFlow()
val queueContentUiState: StateFlow<QueueContentUiState> = combine( private val computedItems: StateFlow<List<QueueItemUi>> = combine(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
) { queue, currentEntry ->
val currentIndex = if (currentEntry != null) {
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
} else {
-1
}
queue.mapIndexed { index, entry ->
val title: String
val subtitle: String
val durationMs: Long
val imageUrl: String?
when (entry) {
is QueueEntry.StreamingTrack -> {
title = entry.track.title
subtitle = entry.track.artists.joinToString(", ") { it.name }
durationMs = entry.track.durationMs
imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
}
is QueueEntry.LocalTrack -> {
title = entry.name
subtitle = entry.artists.joinToString(", ")
durationMs = entry.duration
imageUrl = null
}
}
QueueItemUi(
id = "${entry.url}@$index",
title = title,
subtitle = subtitle,
durationLabel = durationMs.toDurationLabel(),
isCurrent = index == currentIndex,
imageUrl = imageUrl,
originalIndex = index,
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val queueContentUiState: StateFlow<QueueContentUiState> = combine(
computedItems,
reorderBuffer,
queueFilterFlow, queueFilterFlow,
) { queue, currentQueueEntry, filterQuery -> ) { items, buffer, filterQuery ->
val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) {
items.filter { item ->
item.title.lowercase().contains(normalizedFilter) ||
item.subtitle.lowercase().contains(normalizedFilter)
}
} else {
items
}
QueueContentUiState( QueueContentUiState(
filterQuery = filterQuery, filterQuery = filterQuery,
queue = queue, displayItems = buffer ?: filtered,
currentQueueEntry = currentQueueEntry, isFiltered = isFiltered,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -72,13 +143,6 @@ class PlayerQueueContentViewModel(
queueFilterFlow.value = query queueFilterFlow.value = query
} }
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0) return
viewModelScope.launch { viewModelScope.launch {
@ -96,9 +160,67 @@ class PlayerQueueContentViewModel(
} }
} }
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex)
}
}
fun clearQueue() { fun clearQueue() {
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.clear() audioPlayerQueue.clear()
} }
} }
}
fun onDragStart() {
if (reorderBuffer.value != null) return
val currentItems = queueContentUiState.value.displayItems
reorderBuffer.value = currentItems.toList()
}
fun onMove(from: Int, to: Int) {
val buffer = reorderBuffer.value ?: return
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
val item = buffer[from]
val newList = buffer.toMutableList().apply {
removeAt(from)
add(to, item)
}
reorderBuffer.value = newList
moveFromOriginal = item.originalIndex
moveToDisplay = to
}
fun onDragStop() {
val fromOriginal = moveFromOriginal
val toDisplay = moveToDisplay
if (fromOriginal != null && toDisplay != null) {
moveQueueItem(fromOriginal, toDisplay)
}
moveFromOriginal = null
moveToDisplay = null
reorderBuffer.value = null
}
}
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun Long.toDurationLabel(): String {
val totalSeconds = (this / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}