mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
refactor: streamline PlayerQueueContent and ViewModel for better state management
This commit is contained in:
parent
870195bdfd
commit
19c1d0ec35
@ -30,6 +30,7 @@
|
||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
@ -19,9 +19,6 @@ package dev.krtirtho.spotube.modules.shell.player_queue
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
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.Box
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -44,128 +40,54 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
||||
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.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.base.highlight
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
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
|
||||
fun PlayerQueueContent(
|
||||
viewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val logger = rememberLogger("PlayerQueueContent")
|
||||
val queueContentUiState by viewModel.queueContentUiState.collectAsState()
|
||||
val queue = queueContentUiState.queue
|
||||
val currentQueueEntry = queueContentUiState.currentQueueEntry
|
||||
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 state by viewModel.queueContentUiState.collectAsState()
|
||||
val displayItems = state.displayItems
|
||||
val filterQuery = state.filterQuery
|
||||
val isFiltered = state.isFiltered
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyListState =
|
||||
rememberReorderableLazyListState(
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||
lazyListState,
|
||||
onMove = { from, to ->
|
||||
if (isFiltered) return@rememberReorderableLazyListState
|
||||
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
|
||||
viewModel.onMove(from.index, 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) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -204,7 +126,7 @@ fun PlayerQueueContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (displayList.isEmpty()) {
|
||||
if (displayItems.isEmpty()) {
|
||||
Text(
|
||||
text = "No queue entries",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@ -217,7 +139,7 @@ fun PlayerQueueContent(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
items(displayList, key = { item -> item.id }) { item ->
|
||||
items(displayItems, key = { it.id }) { item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||
QueueItemRow(
|
||||
@ -225,7 +147,8 @@ fun PlayerQueueContent(
|
||||
reorderScope = if (isFiltered) null else this,
|
||||
onPlayClick = { viewModel.playQueueItem(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?,
|
||||
onPlayClick: () -> Unit,
|
||||
onRemoveClick: () -> Unit,
|
||||
onDragStarted: () -> Unit,
|
||||
onDragStopped: () -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
@ -263,7 +187,12 @@ private fun QueueItemRow(
|
||||
.size(24.dp)
|
||||
.then(
|
||||
if (reorderScope != null) {
|
||||
with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) }
|
||||
with(reorderScope) {
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = { onDragStarted() },
|
||||
onDragStopped = onDragStopped,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
@ -319,7 +248,7 @@ private fun QueueItemRow(
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
text = item.durationMs.toDurationString(),
|
||||
text = item.durationLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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')}"
|
||||
}
|
||||
|
||||
@ -30,10 +30,20 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
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(
|
||||
val filterQuery: String = "",
|
||||
val queue: List<QueueEntry> = emptyList(),
|
||||
val currentQueueEntry: QueueEntry? = null,
|
||||
val displayItems: List<QueueItemUi> = emptyList(),
|
||||
val isFiltered: Boolean = false,
|
||||
)
|
||||
|
||||
class PlayerQueueContentViewModel(
|
||||
@ -41,18 +51,79 @@ class PlayerQueueContentViewModel(
|
||||
) : ViewModel() {
|
||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||
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 queueContentUiState: StateFlow<QueueContentUiState> = combine(
|
||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
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,
|
||||
) { 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(
|
||||
filterQuery = filterQuery,
|
||||
queue = queue,
|
||||
currentQueueEntry = currentQueueEntry,
|
||||
displayItems = buffer ?: filtered,
|
||||
isFiltered = isFiltered,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -72,13 +143,6 @@ class PlayerQueueContentViewModel(
|
||||
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) {
|
||||
if (index < 0) return
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
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')}"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user