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.
|
- `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.
|
||||||
|
|||||||
@ -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')}"
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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')}"
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user