diff --git a/AGENTS.md b/AGENTS.md index 3f6ee2d8..3e9f8487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 9262e8fe..997da78f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -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,127 +40,53 @@ 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(), 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() } - var moveParams by remember { mutableStateOf?>(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( - 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 - }, - ) - - fun finalizeReorder() { - moveParams?.let { (fromOriginal, toDisplay) -> - logger.i { "Finalizing move from $fromOriginal to $toDisplay (version $queueVersion)" } - viewModel.moveQueueItem(fromOriginal, toDisplay) - } - moveParams = null - } + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState, + onMove = { from, to -> + if (isFiltered) return@rememberReorderableLazyListState + viewModel.onMove(from.index, to.index) + }, + ) Surface(modifier = modifier) { Column( @@ -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 { - 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( - 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')}" -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index ff3d7dca..71faa53b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -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 = emptyList(), - val currentQueueEntry: QueueEntry? = null, + val displayItems: List = 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?>(null) + private var moveFromOriginal: Int? = null + private var moveToDisplay: Int? = null val isQueueVisible: StateFlow = queueVisibilityFlow.asStateFlow() - val queueContentUiState: StateFlow = combine( + private val computedItems: StateFlow> = 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 = 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() } } -} \ No newline at end of file + + 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')}" +}