diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 00297e22a..e1ecc5091 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -55,7 +55,7 @@ jobs:
run: "support/scripts/unit-test"
- name: "Unit Test Results"
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
diff --git a/.github/workflows/continuous_deployment.yml b/.github/workflows/continuous_deployment.yml
index a8dee653d..045008d36 100644
--- a/.github/workflows/continuous_deployment.yml
+++ b/.github/workflows/continuous_deployment.yml
@@ -46,7 +46,7 @@ jobs:
run: "support/scripts/unit-test"
- name: "Unit Test Results"
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
@@ -61,7 +61,7 @@ jobs:
CONFIGURATION: Release
- name: "Upload App Bundle"
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: app-bundle
path: |
diff --git a/.idea/detekt.xml b/.idea/detekt.xml
index ee7289c6b..6ecaedb77 100644
--- a/.idea/detekt.xml
+++ b/.idea/detekt.xml
@@ -1,7 +1,12 @@
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index a269cb1aa..71d57e69b 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,13 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -18,5 +58,9 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 84e6c09b2..55a72f76c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -8,6 +8,7 @@ plugins {
id("com.google.firebase.crashlytics")
id("com.google.devtools.ksp")
alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.detekt)
}
android {
@@ -268,6 +269,10 @@ android {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
+
+ detektPlugins(libs.detekt.formatting)
+
+ lintChecks(libs.compose.lint.checks)
}
buildFeatures.buildConfig = true
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt
new file mode 100644
index 000000000..1a92a7774
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/AddToPlaylistSubmenu.kt
@@ -0,0 +1,49 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.simplecityapps.shuttle.R
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.Playlist
+import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
+
+@Composable
+fun AddToPlaylistSubmenu(
+ modifier: Modifier = Modifier,
+ genre: Genre,
+ expanded: Boolean = false,
+ onDismiss: () -> Unit = {},
+ playlists: List,
+ onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit,
+ onShowCreatePlaylistDialog: (genre: Genre) -> Unit
+) {
+ val playlistData = PlaylistData.Genres(genre)
+
+ DropdownMenu(
+ modifier = modifier,
+ expanded = expanded,
+ onDismissRequest = onDismiss
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.playlist_menu_create_playlist)) },
+ onClick = {
+ onShowCreatePlaylistDialog(genre)
+ onDismiss()
+ }
+ )
+
+ for (playlist in playlists) {
+ DropdownMenuItem(
+ text = { Text(playlist.name) },
+ onClick = {
+ onAddToPlaylist(playlist, playlistData)
+ onDismiss()
+ }
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt
new file mode 100644
index 000000000..69fe03937
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/FastScroller.kt
@@ -0,0 +1,354 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import com.simplecityapps.shuttle.ui.theme.AppTheme
+import kotlin.math.roundToInt
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun FastScroller(
+ getPopupText: (index: Int) -> String?,
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ track: @Composable BoxScope.() -> Unit = {
+ DefaultTrack(modifier = Modifier.fillMaxHeight())
+ },
+ thumb: @Composable () -> Unit = {
+ DefaultThumb()
+ },
+ popup: @Composable (index: Int) -> Unit = { currentItemIndex ->
+ DefaultPopup(text = getPopupText(currentItemIndex))
+ }
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val density = LocalDensity.current
+
+ // Drag-related state.
+ var isDragging by remember { mutableStateOf(false) }
+ var dragThumbOffsetPx by remember { mutableFloatStateOf(0f) }
+ var initialThumbOffset by remember { mutableFloatStateOf(0f) }
+ var cumulativeDrag by remember { mutableFloatStateOf(0f) }
+ var measuredThumbY by remember { mutableFloatStateOf(0f) }
+ var measuredThumbSize by remember { mutableStateOf(IntSize.Zero) }
+ var isVisible by remember { mutableStateOf(true) }
+
+ // Auto-hide the scroller when not scrolling or dragging.
+ LaunchedEffect(state.isScrollInProgress, isDragging) {
+ if (!state.isScrollInProgress && !isDragging) {
+ delay(1500)
+ if (!state.isScrollInProgress && !isDragging) {
+ isVisible = false
+ }
+ } else {
+ isVisible = true
+ }
+ }
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = slideInHorizontally(initialOffsetX = { measuredThumbSize.width }),
+ exit = slideOutHorizontally(targetOffsetX = { measuredThumbSize.width })
+ ) {
+ BoxWithConstraints(
+ modifier = modifier.wrapContentWidth(Alignment.End),
+ contentAlignment = Alignment.TopEnd
+ ) {
+ // Use the available maxHeight as the viewport height.
+ val viewportHeightPx = with(density) { maxHeight.toPx() }
+ val totalItemsCount by remember {
+ derivedStateOf { state.layoutInfo.totalItemsCount }
+ }
+
+ // Compute the thumb scroll state using an average item height.
+ val thumbScrollState = computeThumbScrollState(
+ state = state,
+ totalItemsCount = totalItemsCount,
+ viewportHeightPx = viewportHeightPx,
+ thumbHeight = measuredThumbSize.height
+ )
+ val computedThumbOffsetPx = thumbScrollState.computedThumbOffsetPx
+
+ // When dragging, use the user-controlled offset; otherwise, use the computed value.
+ val thumbOffsetPx = if (isDragging) dragThumbOffsetPx else computedThumbOffsetPx
+
+ // Calculate the thumb center and current item index.
+ val thumbCenter = thumbOffsetPx + measuredThumbSize.height / 2
+ val thumbCenterFraction = ((thumbCenter - measuredThumbSize.height / 2) / (viewportHeightPx - measuredThumbSize.height)).coerceIn(0f, 1f)
+ val currentItemIndex = if (isDragging) {
+ (thumbCenterFraction * (totalItemsCount - 1)).roundToInt()
+ } else {
+ thumbScrollState.currentItemIndex
+ }
+
+ // Draw the track.
+ track()
+
+ // Draw the thumb with drag gesture handling.
+ Box(
+ modifier = Modifier
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
+ .offset { IntOffset(x = 0, y = thumbOffsetPx.roundToInt()) }
+ .onGloballyPositioned { coordinates ->
+ measuredThumbSize = coordinates.size
+ measuredThumbY = coordinates.positionInParent().y
+ }
+ .pointerInput(Unit) {
+ detectVerticalDragGestures(
+ onDragStart = { _: Offset ->
+ isDragging = true
+ initialThumbOffset = measuredThumbY
+ cumulativeDrag = 0f
+ dragThumbOffsetPx = measuredThumbY
+ },
+ onVerticalDrag = { change, dragAmount ->
+ change.consume()
+ cumulativeDrag += dragAmount
+ val (newDragOffset, scrollTarget) = computeDragScrollOffset(
+ initialThumbOffset = initialThumbOffset,
+ cumulativeDrag = cumulativeDrag,
+ viewportHeightPx = viewportHeightPx,
+ thumbHeight = measuredThumbSize.height,
+ totalScrollRangePx = thumbScrollState.totalScrollRangePx,
+ totalItemsCount = totalItemsCount,
+ averageItemHeight = thumbScrollState.averageItemHeight
+ )
+ dragThumbOffsetPx = newDragOffset
+ coroutineScope.launch {
+ state.scrollToItem(scrollTarget.first, scrollTarget.second)
+ }
+ },
+ onDragEnd = { isDragging = false },
+ onDragCancel = { isDragging = false }
+ )
+ },
+ contentAlignment = Alignment.TopEnd
+ ) {
+ thumb()
+ }
+
+ // Position the popup so its bottom aligns with the thumb center.
+ var popupHeight by remember { mutableFloatStateOf(0f) }
+ Box(
+ modifier = Modifier
+ .offset {
+ val desiredPopupY = thumbCenter - popupHeight
+ val finalPopupY = desiredPopupY.coerceAtLeast(0f)
+ IntOffset(0, finalPopupY.roundToInt())
+ }
+ .onGloballyPositioned { coordinates ->
+ popupHeight = coordinates.size.height.toFloat()
+ }
+ ) {
+ AnimatedVisibility(
+ visible = isDragging || LocalInspectionMode.current,
+ enter = fadeIn(tween(durationMillis = 150)),
+ exit = fadeOut(tween(durationMillis = 200))
+ ) {
+ popup(currentItemIndex)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DefaultTrack(
+ modifier: Modifier = Modifier,
+ trackWidth: Dp = 7.dp,
+ trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
+) {
+ Box(
+ modifier = modifier
+ .padding(top = 16.dp, bottom = 16.dp)
+ .clip(RoundedCornerShape(percent = 50))
+ .width(trackWidth)
+ .background(trackColor)
+ )
+}
+
+@Composable
+fun DefaultThumb(
+ modifier: Modifier = Modifier,
+ thumbWidth: Dp = 8.dp,
+ thumbHeight: Dp = 52.dp,
+ thumbColor: Color = MaterialTheme.colorScheme.primary
+) {
+ Box(
+ modifier = modifier
+ .size(width = thumbWidth, height = thumbHeight)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(thumbColor)
+ )
+}
+
+@Composable
+fun DefaultPopup(
+ text: String?,
+ modifier: Modifier = Modifier
+) {
+ text?.let {
+ Box(
+ modifier = modifier
+ .padding(end = 16.dp)
+ .sizeIn(minWidth = 64.dp, minHeight = 64.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(
+ topStartPercent = 50,
+ topEndPercent = 50,
+ bottomStartPercent = 50,
+ bottomEndPercent = 0
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.headlineLarge,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+}
+
+/**
+ * Data class holding computed thumb scroll state, including an estimated average item height.
+ */
+private data class ThumbScrollState(
+ val computedThumbOffsetPx: Float,
+ val currentItemIndex: Int,
+ val totalScrollRangePx: Float,
+ val averageItemHeight: Float
+)
+
+/**
+ * Computes the thumb scroll state using the average height of visible items.
+ *
+ * This makes the fast scroller agnostic to fixed item heights.
+ */
+private fun computeThumbScrollState(
+ state: LazyListState,
+ totalItemsCount: Int,
+ viewportHeightPx: Float,
+ thumbHeight: Int
+): ThumbScrollState {
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val averageItemHeight = if (visibleItems.isNotEmpty()) {
+ visibleItems.sumOf { it.size.toLong() }.toFloat() / visibleItems.size
+ } else {
+ 1f
+ }
+ val totalContentHeightPx = averageItemHeight * totalItemsCount
+ val totalScrollRangePx = (totalContentHeightPx - viewportHeightPx).coerceAtLeast(1f)
+ val currentScrollOffsetPx = state.firstVisibleItemIndex * averageItemHeight + state.firstVisibleItemScrollOffset
+ val scrollFraction = (currentScrollOffsetPx / totalScrollRangePx).coerceIn(0f, 1f)
+ val computedThumbOffsetPx = scrollFraction * (viewportHeightPx - thumbHeight)
+ val thumbCenter = computedThumbOffsetPx + thumbHeight / 2
+ val thumbCenterFraction = ((thumbCenter - thumbHeight / 2) / (viewportHeightPx - thumbHeight)).coerceIn(0f, 1f)
+ val currentItemIndex = (thumbCenterFraction * (totalItemsCount - 1)).roundToInt()
+ return ThumbScrollState(
+ computedThumbOffsetPx = computedThumbOffsetPx,
+ currentItemIndex = currentItemIndex,
+ totalScrollRangePx = totalScrollRangePx,
+ averageItemHeight = averageItemHeight
+ )
+}
+
+/**
+ * Computes a new drag offset and corresponding target scroll position based on the average item height.
+ */
+private fun computeDragScrollOffset(
+ initialThumbOffset: Float,
+ cumulativeDrag: Float,
+ viewportHeightPx: Float,
+ thumbHeight: Int,
+ totalScrollRangePx: Float,
+ totalItemsCount: Int,
+ averageItemHeight: Float
+): Pair> {
+ val newDragOffset = (initialThumbOffset + cumulativeDrag).coerceIn(0f, viewportHeightPx - thumbHeight)
+ val newFraction = newDragOffset / (viewportHeightPx - thumbHeight)
+ val newScrollOffsetPx = newFraction * totalScrollRangePx
+ val targetIndex = (newScrollOffsetPx / averageItemHeight).toInt().coerceIn(0, totalItemsCount - 1)
+ val targetItemOffset = (newScrollOffsetPx % averageItemHeight).toInt()
+ return newDragOffset to (targetIndex to targetItemOffset)
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun FastScrollPreview() {
+ AppTheme {
+ val state = rememberLazyListState(initialFirstVisibleItemIndex = 2)
+ Box(modifier = Modifier.padding(vertical = 16.dp)) {
+ LazyColumn(
+ state = state
+ ) {
+ items(20) {
+ Text(
+ modifier = Modifier
+ .sizeIn(minHeight = 56.dp)
+ .padding(horizontal = 16.dp),
+ text = "Item $it"
+ )
+ }
+ }
+ FastScroller(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 16.dp),
+ state = state,
+ getPopupText = { (it).toString() }
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt
deleted file mode 100644
index 9a7ef6d6c..000000000
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreBinder.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.simplecityapps.shuttle.ui.screens.library.genres
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageButton
-import android.widget.TextView
-import com.simplecityapps.adapter.ViewBinder
-import com.simplecityapps.shuttle.R
-import com.simplecityapps.shuttle.model.Genre
-import com.simplecityapps.shuttle.ui.common.recyclerview.ViewTypes
-import com.squareup.phrase.Phrase
-
-class GenreBinder(val genre: com.simplecityapps.shuttle.model.Genre, private val listener: Listener) : ViewBinder {
- interface Listener {
- fun onGenreSelected(
- genre: com.simplecityapps.shuttle.model.Genre,
- viewHolder: ViewHolder
- )
-
- fun onOverflowClicked(
- view: View,
- genre: com.simplecityapps.shuttle.model.Genre
- ) {}
- }
-
- override fun createViewHolder(parent: ViewGroup): ViewHolder = ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_genre, parent, false))
-
- override fun viewType(): Int = ViewTypes.Genre
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as GenreBinder
-
- if (genre != other.genre) return false
-
- return true
- }
-
- override fun hashCode(): Int = genre.hashCode()
-
- override fun areContentsTheSame(other: Any): Boolean = genre.name == (other as? GenreBinder)?.genre?.name &&
- genre.songCount == (other as? GenreBinder)?.genre?.songCount
-
- class ViewHolder(itemView: View) : ViewBinder.ViewHolder(itemView) {
- private val titleTextView: TextView = itemView.findViewById(R.id.title)
- private val subtitleTextView: TextView = itemView.findViewById(R.id.subtitle)
- private val overflowButton: ImageButton = itemView.findViewById(R.id.overflowButton)
-
- init {
- itemView.setOnClickListener { viewBinder?.listener?.onGenreSelected(viewBinder!!.genre, this) }
- overflowButton.setOnClickListener { viewBinder?.listener?.onOverflowClicked(it, viewBinder!!.genre) }
- }
-
- override fun bind(
- viewBinder: GenreBinder,
- isPartial: Boolean
- ) {
- super.bind(viewBinder, isPartial)
-
- titleTextView.text = viewBinder.genre.name
- if (viewBinder.genre.songCount == 0) {
- subtitleTextView.text = itemView.resources.getString(R.string.song_list_empty)
- } else {
- subtitleTextView.text =
- Phrase
- .fromPlural(itemView.context, R.plurals.songsPlural, viewBinder.genre.songCount)
- .put("count", viewBinder.genre.songCount)
- .format()
- }
- }
- }
-}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt
new file mode 100644
index 000000000..b3cd57c30
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreList.kt
@@ -0,0 +1,117 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.simplecityapps.mediaprovider.Progress
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.Playlist
+import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
+
+@Composable
+fun GenreList(
+ viewState: GenreListViewModel.ViewState,
+ playlists: List,
+ setLoadingState: (GenreListFragment.LoadingState) -> Unit,
+ setLoadingProgress: (progress: Progress?) -> Unit,
+ onSelectGenre: (genre: Genre) -> Unit,
+ onPlayGenre: (Genre) -> Unit,
+ onAddToQueue: (Genre) -> Unit,
+ onPlayNext: (Genre) -> Unit,
+ onExclude: (Genre) -> Unit,
+ onEditTags: (Genre) -> Unit,
+ onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit,
+ onShowCreatePlaylistDialog: (genre: Genre) -> Unit
+) {
+ when (viewState) {
+ is GenreListViewModel.ViewState.Scanning -> {
+ setLoadingState(GenreListFragment.LoadingState.Scanning)
+ setLoadingProgress(viewState.progress)
+ }
+
+ is GenreListViewModel.ViewState.Loading -> {
+ setLoadingState(GenreListFragment.LoadingState.Loading)
+ }
+
+ is GenreListViewModel.ViewState.Ready -> {
+ if (viewState.genres.isEmpty()) {
+ setLoadingState(GenreListFragment.LoadingState.Empty)
+ } else {
+ setLoadingState(GenreListFragment.LoadingState.None)
+ }
+
+ GenreList(
+ genres = viewState.genres,
+ playlists = playlists,
+ onSelectGenre = onSelectGenre,
+ onPlayGenre = onPlayGenre,
+ onAddToQueue = onAddToQueue,
+ onPlayNext = onPlayNext,
+ onExclude = onExclude,
+ onEditTags = onEditTags,
+ onAddToPlaylist = onAddToPlaylist,
+ onShowCreatePlaylistDialog = onShowCreatePlaylistDialog
+ )
+ }
+ }
+}
+
+@Composable
+private fun GenreList(
+ genres: List,
+ playlists: List,
+ onSelectGenre: (genre: Genre) -> Unit,
+ onPlayGenre: (Genre) -> Unit,
+ onAddToQueue: (Genre) -> Unit,
+ onPlayNext: (Genre) -> Unit,
+ onExclude: (Genre) -> Unit,
+ onEditTags: (Genre) -> Unit,
+ onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit,
+ modifier: Modifier = Modifier,
+ onShowCreatePlaylistDialog: (genre: Genre) -> Unit
+) {
+ val state = rememberLazyListState()
+
+ Box(modifier = modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("genres-list-lazy-column"),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp),
+ state = state
+ ) {
+ items(genres + genres + genres + genres) { genre ->
+ GenreListItem(
+ genre = genre,
+ playlists = playlists,
+ onSelectGenre = onSelectGenre,
+ onPlayGenre = onPlayGenre,
+ onAddToQueue = onAddToQueue,
+ onPlayNext = onPlayNext,
+ onExclude = onExclude,
+ onEditTags = onEditTags,
+ onAddToPlaylist = onAddToPlaylist,
+ onShowCreatePlaylistDialog = onShowCreatePlaylistDialog
+ )
+ }
+ }
+ FastScroller(
+ modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
+ state = state,
+ getPopupText = { index ->
+ (genres + genres + genres + genres)[index].name.firstOrNull()?.toString()
+ }
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt
index 4622f18f7..3cd486b28 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListFragment.kt
@@ -1,27 +1,24 @@
package com.simplecityapps.shuttle.ui.screens.library.genres
import android.os.Bundle
-import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
-import androidx.appcompat.widget.PopupMenu
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
-import androidx.recyclerview.widget.RecyclerView
-import com.simplecityapps.adapter.RecyclerAdapter
-import com.simplecityapps.adapter.RecyclerListener
-import com.simplecityapps.adapter.ViewBinder
import com.simplecityapps.mediaprovider.Progress
import com.simplecityapps.shuttle.R
-import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.Song
import com.simplecityapps.shuttle.ui.common.autoCleared
import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog
-import com.simplecityapps.shuttle.ui.common.dialog.showExcludeDialog
import com.simplecityapps.shuttle.ui.common.error.userDescription
-import com.simplecityapps.shuttle.ui.common.recyclerview.SectionedAdapter
import com.simplecityapps.shuttle.ui.common.view.CircularLoadingView
import com.simplecityapps.shuttle.ui.common.view.HorizontalLoadingView
import com.simplecityapps.shuttle.ui.screens.library.genres.detail.GenreDetailFragmentArgs
@@ -29,6 +26,7 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFr
import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuPresenter
import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView
+import com.simplecityapps.shuttle.ui.theme.AppTheme
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -36,25 +34,18 @@ import javax.inject.Inject
@AndroidEntryPoint
class GenreListFragment :
Fragment(),
- GenreBinder.Listener,
- GenreListContract.View,
CreatePlaylistDialogFragment.Listener {
- private var adapter: RecyclerAdapter by autoCleared()
-
- private var recyclerView: RecyclerView by autoCleared()
+ private var composeView: ComposeView by autoCleared()
private var circularLoadingView: CircularLoadingView by autoCleared()
private var horizontalLoadingView: HorizontalLoadingView by autoCleared()
- @Inject
- lateinit var presenter: GenreListPresenter
+ private val viewModel: GenreListViewModel by viewModels()
@Inject
lateinit var playlistMenuPresenter: PlaylistMenuPresenter
private lateinit var playlistMenuView: PlaylistMenuView
- private var recyclerViewState: Parcelable? = null
-
// Lifecycle
override fun onCreateView(
@@ -69,116 +60,130 @@ class GenreListFragment :
) {
super.onViewCreated(view, savedInstanceState)
- playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager)
-
- adapter =
- object : SectionedAdapter(viewLifecycleOwner.lifecycleScope) {
- override fun getSectionName(viewBinder: ViewBinder?): String? = (viewBinder as? GenreBinder)?.genre?.let { genre ->
- presenter.getFastscrollPrefix(genre)
- }
- }
- recyclerView = view.findViewById(R.id.recyclerView)
- recyclerView.adapter = adapter
- recyclerView.setRecyclerListener(RecyclerListener())
-
circularLoadingView = view.findViewById(R.id.circularLoadingView)
horizontalLoadingView = view.findViewById(R.id.horizontalLoadingView)
- savedInstanceState?.getParcelable(ARG_RECYCLER_STATE)?.let { recyclerViewState = it }
-
- presenter.bindView(this)
+ playlistMenuView = PlaylistMenuView(requireContext(), playlistMenuPresenter, childFragmentManager)
playlistMenuPresenter.bindView(playlistMenuView)
- }
-
- override fun onResume() {
- super.onResume()
-
- presenter.loadGenres(false)
- }
- override fun onPause() {
- super.onPause()
-
- recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState()
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- outState.putParcelable(ARG_RECYCLER_STATE, recyclerViewState)
- super.onSaveInstanceState(outState)
+ composeView = view.findViewById(R.id.composeView)
+
+ composeView.setContent {
+ val viewState by viewModel.viewState.collectAsState()
+
+ val theme by viewModel.theme.collectAsStateWithLifecycle()
+ val accent by viewModel.accent.collectAsStateWithLifecycle()
+ val extraDark by viewModel.extraDark.collectAsStateWithLifecycle()
+
+ AppTheme(
+ theme = theme,
+ accent = accent,
+ extraDark = extraDark
+ ) {
+ GenreList(
+ viewState = viewState,
+ playlists = playlistMenuPresenter.playlists,
+ setLoadingState = {
+ setLoadingState(it)
+ },
+ setLoadingProgress = {
+ setLoadingProgress(it)
+ },
+ onSelectGenre = {
+ onGenreSelected(it)
+ },
+ onPlayGenre = { genre ->
+ viewModel.play(genre) { result ->
+ result.onFailure { error -> showLoadError(error as Error) }
+ }
+ },
+ onAddToQueue = { genre ->
+ viewModel.addToQueue(genre) { result ->
+ result.onSuccess { genre ->
+ onAddedToQueue(genre)
+ }
+ }
+ },
+ onPlayNext = { genre ->
+ viewModel.playNext(genre) { result ->
+ result.onSuccess { genre ->
+ onAddedToQueue(genre)
+ }
+ }
+ },
+ onExclude = { genre ->
+ viewModel.exclude(genre)
+ },
+ onEditTags = { genre ->
+ viewModel.editTags(genre) { result ->
+ result.onSuccess { songs ->
+ showTagEditor(songs)
+ }
+ }
+ },
+ onAddToPlaylist = { playlist, playlistData ->
+ playlistMenuPresenter.addToPlaylist(playlist, playlistData)
+ },
+ onShowCreatePlaylistDialog = { genre ->
+ CreatePlaylistDialogFragment.newInstance(
+ PlaylistData.Genres(genre),
+ context?.getString(R.string.playlist_create_dialog_playlist_name_hint)
+ ).show(childFragmentManager)
+ }
+ )
+ }
+ }
}
override fun onDestroyView() {
- presenter.unbindView()
playlistMenuPresenter.unbindView()
super.onDestroyView()
}
- // GenreListContract.View Implementation
-
- override fun setGenres(
- genres: List,
- resetPosition: Boolean
- ) {
- if (resetPosition) {
- adapter.clear()
- }
-
- val data = genres.map { genre -> GenreBinder(genre, this) }.toMutableList()
-
- adapter.update(data) {
- recyclerViewState?.let {
- recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState)
- recyclerViewState = null
- }
- }
- }
-
- override fun onAddedToQueue(genre: com.simplecityapps.shuttle.model.Genre) {
+ fun onAddedToQueue(genre: Genre) {
Toast.makeText(context, Phrase.from(requireContext(), R.string.queue_item_added).put("item_name", genre.name).format(), Toast.LENGTH_SHORT).show()
}
- override fun setLoadingState(state: GenreListContract.LoadingState) {
+ private fun setLoadingState(state: LoadingState) {
when (state) {
- is GenreListContract.LoadingState.Scanning -> {
+ is LoadingState.Scanning -> {
horizontalLoadingView.setState(HorizontalLoadingView.State.Loading(getString(R.string.library_scan_in_progress)))
circularLoadingView.setState(CircularLoadingView.State.None)
}
- is GenreListContract.LoadingState.Loading -> {
+
+ is LoadingState.Loading -> {
horizontalLoadingView.setState(HorizontalLoadingView.State.None)
circularLoadingView.setState(CircularLoadingView.State.Loading(getString(R.string.loading)))
}
- is GenreListContract.LoadingState.Empty -> {
+
+ is LoadingState.Empty -> {
horizontalLoadingView.setState(HorizontalLoadingView.State.None)
circularLoadingView.setState(CircularLoadingView.State.Empty(getString(R.string.genre_list_empty)))
}
- is GenreListContract.LoadingState.None -> {
+
+ is LoadingState.None -> {
horizontalLoadingView.setState(HorizontalLoadingView.State.None)
circularLoadingView.setState(CircularLoadingView.State.None)
}
}
}
- override fun setLoadingProgress(progress: Progress?) {
+ private fun setLoadingProgress(progress: Progress?) {
progress?.let {
horizontalLoadingView.setProgress(progress.asFloat())
}
}
- override fun showLoadError(error: Error) {
+ fun showLoadError(error: Error) {
Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show()
}
- override fun showTagEditor(songs: List) {
+ fun showTagEditor(songs: List) {
TagEditorAlertDialog.newInstance(songs).show(childFragmentManager)
}
- // GenreBinder.Listener Implementation
-
- override fun onGenreSelected(
- genre: com.simplecityapps.shuttle.model.Genre,
- viewHolder: GenreBinder.ViewHolder
- ) {
+ private fun onGenreSelected(genre: Genre) {
if (findNavController().currentDestination?.id != R.id.genreDetailFragment) {
findNavController().navigate(
R.id.action_libraryFragment_to_genreDetailFragment,
@@ -187,50 +192,6 @@ class GenreListFragment :
}
}
- override fun onOverflowClicked(
- view: View,
- genre: com.simplecityapps.shuttle.model.Genre
- ) {
- val popupMenu = PopupMenu(requireContext(), view)
- popupMenu.inflate(R.menu.menu_popup)
- TagEditorMenuSanitiser.sanitise(popupMenu.menu, genre.mediaProviders)
-
- playlistMenuView.createPlaylistMenu(popupMenu.menu)
-
- popupMenu.setOnMenuItemClickListener { menuItem ->
- if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Genres(genre))) {
- return@setOnMenuItemClickListener true
- } else {
- when (menuItem.itemId) {
- R.id.play -> {
- presenter.play(genre)
- return@setOnMenuItemClickListener true
- }
- R.id.queue -> {
- presenter.addToQueue(genre)
- return@setOnMenuItemClickListener true
- }
- R.id.playNext -> {
- presenter.playNext(genre)
- return@setOnMenuItemClickListener true
- }
- R.id.exclude -> {
- showExcludeDialog(requireContext(), genre.name) {
- presenter.exclude(genre)
- }
- return@setOnMenuItemClickListener true
- }
- R.id.editTags -> {
- presenter.editTags(genre)
- return@setOnMenuItemClickListener true
- }
- }
- }
- false
- }
- popupMenu.show()
- }
-
// CreatePlaylistDialogFragment.Listener Implementation
override fun onSave(
@@ -245,8 +206,13 @@ class GenreListFragment :
companion object {
const val TAG = "GenreListFragment"
- const val ARG_RECYCLER_STATE = "recycler_state"
-
fun newInstance() = GenreListFragment()
}
+
+ sealed class LoadingState {
+ data object Scanning : LoadingState()
+ data object Loading : LoadingState()
+ data object Empty : LoadingState()
+ data object None : LoadingState()
+ }
}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt
new file mode 100644
index 000000000..003fb01ce
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListItem.kt
@@ -0,0 +1,107 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.simplecityapps.shuttle.R
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.MediaProviderType
+import com.simplecityapps.shuttle.model.Playlist
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import com.simplecityapps.shuttle.sorting.PlaylistSongSortOrder
+import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
+import com.simplecityapps.shuttle.ui.theme.AppTheme
+
+@Composable
+fun GenreListItem(
+ genre: Genre,
+ playlists: List,
+ modifier: Modifier = Modifier,
+ onSelectGenre: (genre: Genre) -> Unit = {},
+ onPlayGenre: (Genre) -> Unit = {},
+ onAddToQueue: (Genre) -> Unit = {},
+ onPlayNext: (Genre) -> Unit = {},
+ onExclude: (Genre) -> Unit = {},
+ onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit = { _, _ -> },
+ onEditTags: (Genre) -> Unit = {},
+ onShowCreatePlaylistDialog: (genre: Genre) -> Unit = {}
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ Modifier
+ .padding(start = 8.dp)
+ .weight(1f)
+ .clickable { onSelectGenre(genre) }
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = genre.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ // Todo: Manually replacing "{count}" is not ideal. But, the Phrase library doesn't render correctly in Compose.
+ // Will need to come up with a better solution.
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = pluralStringResource(R.plurals.songsPlural, genre.songCount, genre.songCount)
+ .replace("{count}", genre.songCount.toString()),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ GenreMenu(
+ genre,
+ playlists = playlists,
+ onPlayGenre = onPlayGenre,
+ onAddToQueue = onAddToQueue,
+ onPlayNext = onPlayNext,
+ onExclude = onExclude,
+ onEditTags = onEditTags,
+ onAddToPlaylist = onAddToPlaylist,
+ onShowCreatePlaylistDialog = onShowCreatePlaylistDialog
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun GenreListItemPreview() {
+ AppTheme(
+ accent = GeneralPreferenceManager.Accent.Default
+ ) {
+ GenreListItem(
+ genre = Genre(
+ name = "Genre",
+ songCount = 1,
+ duration = 10,
+ mediaProviders = listOf(MediaProviderType.MediaStore)
+ ),
+ playlists = listOf(
+ Playlist(
+ id = 1,
+ name = "Playlist",
+ songCount = 1,
+ duration = 10,
+ sortOrder = PlaylistSongSortOrder.SongName,
+ mediaProvider = MediaProviderType.MediaStore,
+ externalId = null
+ )
+ )
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt
deleted file mode 100644
index 78dc0fb81..000000000
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListPresenter.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-package com.simplecityapps.shuttle.ui.screens.library.genres
-
-import com.simplecityapps.mediaprovider.MediaImporter
-import com.simplecityapps.mediaprovider.Progress
-import com.simplecityapps.mediaprovider.repository.genres.GenreQuery
-import com.simplecityapps.mediaprovider.repository.genres.GenreRepository
-import com.simplecityapps.mediaprovider.repository.songs.SongRepository
-import com.simplecityapps.playback.PlaybackManager
-import com.simplecityapps.playback.queue.QueueManager
-import com.simplecityapps.shuttle.model.Genre
-import com.simplecityapps.shuttle.model.MediaProviderType
-import com.simplecityapps.shuttle.query.SongQuery
-import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter
-import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
-
-class GenreListContract {
- sealed class LoadingState {
- object Scanning : LoadingState()
-
- object Loading : LoadingState()
-
- object Empty : LoadingState()
-
- object None : LoadingState()
- }
-
- interface View {
- fun setGenres(
- genres: List,
- resetPosition: Boolean
- )
-
- fun onAddedToQueue(genre: Genre)
-
- fun setLoadingState(state: LoadingState)
-
- fun setLoadingProgress(progress: Progress?)
-
- fun showLoadError(error: Error)
-
- fun showTagEditor(songs: List)
- }
-
- interface Presenter {
- fun loadGenres(resetPosition: Boolean)
-
- fun addToQueue(genre: Genre)
-
- fun playNext(genre: Genre)
-
- fun exclude(genre: Genre)
-
- fun editTags(genre: Genre)
-
- fun play(genre: Genre)
-
- fun getFastscrollPrefix(genre: Genre): String?
- }
-}
-
-class GenreListPresenter
-@Inject
-constructor(
- private val genreRepository: GenreRepository,
- private val songRepository: SongRepository,
- private val playbackManager: PlaybackManager,
- private val mediaImporter: MediaImporter,
- private val queueManager: QueueManager
-) : BasePresenter(),
- GenreListContract.Presenter {
- private var genres: List? = null
-
- private val mediaImporterListener =
- object : MediaImporter.Listener {
- override fun onSongImportProgress(
- providerType: MediaProviderType,
- message: String,
- progress: Progress?
- ) {
- view?.setLoadingProgress(progress)
- }
- }
-
- override fun unbindView() {
- super.unbindView()
-
- mediaImporter.listeners.remove(mediaImporterListener)
- }
-
- override fun loadGenres(resetPosition: Boolean) {
- if (genres == null) {
- if (mediaImporter.isImporting) {
- view?.setLoadingState(GenreListContract.LoadingState.Scanning)
- } else {
- view?.setLoadingState(GenreListContract.LoadingState.Loading)
- }
- }
- launch {
- genreRepository.getGenres(GenreQuery.All())
- .distinctUntilChanged()
- .flowOn(Dispatchers.IO)
- .collect { genres ->
- if (genres.isEmpty()) {
- if (mediaImporter.isImporting) {
- mediaImporter.listeners.add(mediaImporterListener)
- view?.setLoadingState(GenreListContract.LoadingState.Scanning)
- } else {
- mediaImporter.listeners.remove(mediaImporterListener)
- view?.setLoadingState(GenreListContract.LoadingState.Empty)
- }
- } else {
- mediaImporter.listeners.remove(mediaImporterListener)
- view?.setLoadingState(GenreListContract.LoadingState.None)
- }
- this@GenreListPresenter.genres = genres
- view?.setGenres(genres, resetPosition)
- }
- }
- }
-
- override fun addToQueue(genre: Genre) {
- launch {
- val songs =
- genreRepository.getSongsForGenre(genre.name, SongQuery.All())
- .firstOrNull()
- .orEmpty()
- playbackManager.addToQueue(songs)
- view?.onAddedToQueue(genre)
- }
- }
-
- override fun playNext(genre: Genre) {
- launch {
- val songs =
- genreRepository.getSongsForGenre(genre.name, SongQuery.All())
- .firstOrNull()
- .orEmpty()
- playbackManager.playNext(songs)
- view?.onAddedToQueue(genre)
- }
- }
-
- override fun exclude(genre: Genre) {
- launch {
- val songs =
- genreRepository.getSongsForGenre(genre.name, SongQuery.All())
- .firstOrNull()
- .orEmpty()
- songRepository.setExcluded(songs, true)
- queueManager.remove(queueManager.getQueue().filter { queueItem -> songs.contains(queueItem.song) })
- }
- }
-
- override fun editTags(genre: Genre) {
- launch {
- val songs =
- genreRepository.getSongsForGenre(genre.name, SongQuery.All())
- .firstOrNull()
- .orEmpty()
- view?.showTagEditor(songs)
- }
- }
-
- override fun play(genre: Genre) {
- launch {
- val songs =
- genreRepository.getSongsForGenre(genre.name, SongQuery.All())
- .firstOrNull()
- .orEmpty()
- if (queueManager.setQueue(songs)) {
- playbackManager.load { result ->
- result.onSuccess { playbackManager.play() }
- result.onFailure { error -> view?.showLoadError(error as Error) }
- }
- }
- }
- }
-
- override fun getFastscrollPrefix(genre: Genre): String? = genre.name.first().toString()
-}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt
new file mode 100644
index 000000000..058e651e0
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreListViewModel.kt
@@ -0,0 +1,119 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import androidx.annotation.OpenForTesting
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.simplecityapps.mediaprovider.MediaImportObserver
+import com.simplecityapps.mediaprovider.Progress
+import com.simplecityapps.mediaprovider.SongImportState
+import com.simplecityapps.mediaprovider.repository.genres.GenreQuery
+import com.simplecityapps.mediaprovider.repository.genres.GenreRepository
+import com.simplecityapps.mediaprovider.repository.songs.SongRepository
+import com.simplecityapps.playback.PlaybackManager
+import com.simplecityapps.playback.queue.QueueManager
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.Song
+import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
+import com.simplecityapps.shuttle.query.SongQuery
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.launch
+
+@OpenForTesting
+@HiltViewModel
+class GenreListViewModel @Inject constructor(
+ private val genreRepository: GenreRepository,
+ private val songRepository: SongRepository,
+ private val playbackManager: PlaybackManager,
+ private val queueManager: QueueManager,
+ preferenceManager: GeneralPreferenceManager,
+ mediaImportObserver: MediaImportObserver
+) : ViewModel() {
+ private val _viewState = MutableStateFlow(ViewState.Loading)
+ val viewState = _viewState.asStateFlow()
+
+ init {
+ combine(
+ genreRepository.getGenres(GenreQuery.All()),
+ mediaImportObserver.songImportState,
+ mediaImportObserver.playlistImportState
+ ) { genres, songImportState, playlistImportState ->
+ if (songImportState is SongImportState.ImportProgress) {
+ _viewState.emit(ViewState.Scanning(songImportState.progress))
+ } else {
+ _viewState.emit(ViewState.Ready(genres))
+ }
+ }
+ .onStart {
+ _viewState.emit(ViewState.Loading)
+ }
+ .launchIn(viewModelScope)
+ }
+
+ val theme = preferenceManager.theme(viewModelScope)
+ val accent = preferenceManager.accent(viewModelScope)
+ val extraDark = preferenceManager.extraDark(viewModelScope)
+
+ fun play(genre: Genre, completion: (Result) -> Unit) {
+ viewModelScope.launch {
+ val songs = getSongsForGenreOrEmpty(genre)
+ if (queueManager.setQueue(songs)) {
+ playbackManager.load { result ->
+ result.onSuccess { playbackManager.play() }
+ completion(result)
+ }
+ }
+ }
+ }
+
+ fun addToQueue(genre: Genre, completion: (Result) -> Unit) {
+ viewModelScope.launch {
+ val songs = getSongsForGenreOrEmpty(genre)
+ playbackManager.addToQueue(songs)
+ completion(Result.success(genre))
+ }
+ }
+
+ fun playNext(genre: Genre, completion: (Result) -> Unit) {
+ viewModelScope.launch {
+ val songs = getSongsForGenreOrEmpty(genre)
+ playbackManager.playNext(songs)
+ completion(Result.success(genre))
+ }
+ }
+
+ fun exclude(genre: Genre) {
+ viewModelScope.launch {
+ val songs = getSongsForGenreOrEmpty(genre)
+ songRepository.setExcluded(songs, true)
+ queueManager.remove(
+ queueManager
+ .getQueue()
+ .filter { queueItem -> songs.contains(queueItem.song) }
+ )
+ }
+ }
+
+ fun editTags(genre: Genre, completion: (Result>) -> Unit) {
+ viewModelScope.launch {
+ val songs = getSongsForGenreOrEmpty(genre)
+ completion(Result.success(songs))
+ }
+ }
+
+ private suspend fun getSongsForGenreOrEmpty(genre: Genre) = genreRepository.getSongsForGenre(genre.name, SongQuery.All())
+ .firstOrNull()
+ .orEmpty()
+
+ sealed class ViewState {
+ data class Scanning(val progress: Progress?) : ViewState()
+ data object Loading : ViewState()
+ data class Ready(val genres: List) : ViewState()
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt
new file mode 100644
index 000000000..15e0bc9eb
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/genres/GenreMenu.kt
@@ -0,0 +1,118 @@
+package com.simplecityapps.shuttle.ui.screens.library.genres
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.simplecityapps.shuttle.R
+import com.simplecityapps.shuttle.model.Genre
+import com.simplecityapps.shuttle.model.Playlist
+import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistData
+
+@Composable
+fun GenreMenu(
+ genre: Genre,
+ playlists: List,
+ onPlayGenre: (Genre) -> Unit,
+ onAddToQueue: (Genre) -> Unit,
+ onPlayNext: (Genre) -> Unit,
+ onExclude: (Genre) -> Unit,
+ onEditTags: (Genre) -> Unit,
+ onAddToPlaylist: (playlist: Playlist, playlistData: PlaylistData) -> Unit,
+ modifier: Modifier = Modifier,
+ onShowCreatePlaylistDialog: (genre: Genre) -> Unit
+) {
+ var isMenuOpened by remember { mutableStateOf(false) }
+ var isAddToPlaylistSubmenuOpen by remember { mutableStateOf(false) }
+
+ IconButton(
+ modifier = modifier,
+ onClick = { isMenuOpened = true }
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Genre menu",
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ DropdownMenu(
+ expanded = isMenuOpened,
+ onDismissRequest = { isMenuOpened = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_play)) },
+ onClick = {
+ onPlayGenre(genre)
+ isMenuOpened = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_add_to_queue)) },
+ onClick = {
+ onAddToQueue(genre)
+ isMenuOpened = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_add_to_playlist)) },
+ onClick = {
+ isMenuOpened = false
+ isAddToPlaylistSubmenuOpen = true
+ },
+ trailingIcon = {
+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_play_next)) },
+ onClick = {
+ onPlayNext(genre)
+ isMenuOpened = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_exclude)) },
+ onClick = {
+ onExclude(genre)
+ isMenuOpened = false
+ }
+ )
+
+ val supportsTagEditing = genre.mediaProviders.all { mediaProvider ->
+ mediaProvider.supportsTagEditing
+ }
+
+ if (supportsTagEditing) {
+ DropdownMenuItem(
+ text = { Text(stringResource(id = R.string.menu_title_edit_tags)) },
+ onClick = {
+ onEditTags(genre)
+ isMenuOpened = false
+ }
+ )
+ }
+ }
+ AddToPlaylistSubmenu(
+ genre = genre,
+ expanded = isAddToPlaylistSubmenuOpen,
+ onDismiss = { isAddToPlaylistSubmenuOpen = false },
+ playlists = playlists,
+ onAddToPlaylist = onAddToPlaylist,
+ onShowCreatePlaylistDialog = onShowCreatePlaylistDialog
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt
index 0d4c48e10..54541ed91 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/theme/Color.kt
@@ -12,7 +12,7 @@ fun Theme.getColorScheme(useDark: Boolean = false): ColorScheme = if (useDark) d
val ShuttleTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF005389),
+ primary = Color(0xFF0492ea),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF0079C3),
onPrimaryContainer = Color(0xFFFFFFFF),
@@ -39,7 +39,7 @@ val ShuttleTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2C3137),
inverseOnSurface = Color(0xFFEDF1F8),
- inversePrimary = Color(0xFF9BCBFF),
+ inversePrimary = Color(0xFF0068A5),
surfaceDim = Color(0xFFD7DAE2),
surfaceBright = Color(0xFFF8F9FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -47,11 +47,11 @@ val ShuttleTheme = Theme(
surfaceContainer = Color(0xFFEBEEF6),
surfaceContainerHigh = Color(0xFFE5E8F0),
surfaceContainerHighest = Color(0xFFDFE2EA),
- surfaceTint = Color(0xFF005389)
+ surfaceTint = Color(0xFF0492ea)
),
dark = ColorScheme(
- primary = Color(0xFF9BCBFF),
- onPrimary = Color(0xFF003256),
+ primary = Color(0xFF0068A5),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF0079C3),
onPrimaryContainer = Color(0xFFFFFFFF),
secondary = Color(0xFFA8C9F0),
@@ -85,13 +85,13 @@ val ShuttleTheme = Theme(
surfaceContainer = Color(0xFF1C2025),
surfaceContainerHigh = Color(0xFF262A30),
surfaceContainerHighest = Color(0xFF31353B),
- surfaceTint = Color(0xFF9BCBFF)
+ surfaceTint = Color(0xFF0068A5)
)
)
val OrangeTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF904A42),
+ primary = Color(0xFFF44336),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDAD5),
onPrimaryContainer = Color(0xFF3B0906),
@@ -118,7 +118,7 @@ val OrangeTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF392E2C),
inverseOnSurface = Color(0xFFFFEDEA),
- inversePrimary = Color(0xFFFFB4A9),
+ inversePrimary = Color(0xFFB71C1C),
surfaceDim = Color(0xFFE8D6D3),
surfaceBright = Color(0xFFFFF8F7),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -126,12 +126,12 @@ val OrangeTheme = Theme(
surfaceContainer = Color(0xFFFCEAE7),
surfaceContainerHigh = Color(0xFFF7E4E1),
surfaceContainerHighest = Color(0xFFF1DEDC),
- surfaceTint = Color(0xFF904A42)
+ surfaceTint = Color(0xFFF44336)
),
dark = ColorScheme(
- primary = Color(0xFFFFB4A9),
- onPrimary = Color(0xFF561E18),
+ primary = Color(0xFFB71C1C),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF73342C),
onPrimaryContainer = Color(0xFFFFDAD5),
secondary = Color(0xFFE7BDB7),
@@ -157,7 +157,7 @@ val OrangeTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFF1DEDC),
inverseOnSurface = Color(0xFF392E2C),
- inversePrimary = Color(0xFF904A42),
+ inversePrimary = Color(0xFFF44336),
surfaceDim = Color(0xFF1A1110),
surfaceBright = Color(0xFF423735),
surfaceContainerLowest = Color(0xFF140C0B),
@@ -165,13 +165,13 @@ val OrangeTheme = Theme(
surfaceContainer = Color(0xFF271D1C),
surfaceContainerHigh = Color(0xFF322826),
surfaceContainerHighest = Color(0xFF3D3231),
- surfaceTint = Color(0xFFFFB4A9)
+ surfaceTint = Color(0xFFB71C1C)
)
)
val CyanTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF006A6A),
+ primary = Color(0xFF00AFAF),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF9CF1F0),
onPrimaryContainer = Color(0xFF002020),
@@ -198,7 +198,7 @@ val CyanTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2B3231),
inverseOnSurface = Color(0xFFECF2F1),
- inversePrimary = Color(0xFF80D5D4),
+ inversePrimary = Color(0xFF00AFAF),
surfaceDim = Color(0xFFD5DBDA),
surfaceBright = Color(0xFFF4FBFA),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -206,11 +206,11 @@ val CyanTheme = Theme(
surfaceContainer = Color(0xFFE9EFEE),
surfaceContainerHigh = Color(0xFFE3E9E9),
surfaceContainerHighest = Color(0xFFDDE4E3),
- surfaceTint = Color(0xFF006A6A)
+ surfaceTint = Color(0xFF00AFAF)
),
dark = ColorScheme(
- primary = Color(0xFF80D5D4),
- onPrimary = Color(0xFF003737),
+ primary = Color(0xFF00AFAF),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF004F4F),
onPrimaryContainer = Color(0xFF9CF1F0),
secondary = Color(0xFFB0CCCB),
@@ -236,7 +236,7 @@ val CyanTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFDDE4E3),
inverseOnSurface = Color(0xFF2B3231),
- inversePrimary = Color(0xFF006A6A),
+ inversePrimary = Color(0xFF00AFAF),
surfaceDim = Color(0xFF0E1514),
surfaceBright = Color(0xFF343A3A),
surfaceContainerLowest = Color(0xFF090F0F),
@@ -250,7 +250,7 @@ val CyanTheme = Theme(
val PurpleTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF7F4D7A),
+ primary = Color(0xFF8B0F8E),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFD7F6),
onPrimaryContainer = Color(0xFF330833),
@@ -277,7 +277,7 @@ val PurpleTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF352E33),
inverseOnSurface = Color(0xFFFAEDF4),
- inversePrimary = Color(0xFFF0B3E7),
+ inversePrimary = Color(0xFF8B0F8E),
surfaceDim = Color(0xFFE3D7DD),
surfaceBright = Color(0xFFFFF7F9),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -285,11 +285,11 @@ val PurpleTheme = Theme(
surfaceContainer = Color(0xFFF7EBF1),
surfaceContainerHigh = Color(0xFFF1E5EB),
surfaceContainerHighest = Color(0xFFEBDFE6),
- surfaceTint = Color(0xFF7F4D7A)
+ surfaceTint = Color(0xFF8B0F8E)
),
dark = ColorScheme(
- primary = Color(0xFFF0B3E7),
- onPrimary = Color(0xFF4B1F4A),
+ primary = Color(0xFF8B0F8E),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF653661),
onPrimaryContainer = Color(0xFFFFD7F6),
secondary = Color(0xFFDABFD3),
@@ -315,7 +315,7 @@ val PurpleTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEBDFE6),
inverseOnSurface = Color(0xFF352E33),
- inversePrimary = Color(0xFF7F4D7A),
+ inversePrimary = Color(0xFF8B0F8E),
surfaceDim = Color(0xFF171216),
surfaceBright = Color(0xFF3E373C),
surfaceContainerLowest = Color(0xFF120D11),
@@ -323,13 +323,13 @@ val PurpleTheme = Theme(
surfaceContainer = Color(0xFF241E22),
surfaceContainerHigh = Color(0xFF2F282D),
surfaceContainerHighest = Color(0xFF3A3338),
- surfaceTint = Color(0xFFF0B3E7)
+ surfaceTint = Color(0xFF8B0F8E)
)
)
val GreenTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF3B6939),
+ primary = Color(0xFF4CAF50),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFBCF0B4),
onPrimaryContainer = Color(0xFF002204),
@@ -356,7 +356,7 @@ val GreenTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2D322C),
inverseOnSurface = Color(0xFFEFF2E9),
- inversePrimary = Color(0xFFA1D39A),
+ inversePrimary = Color(0xFF306E32),
surfaceDim = Color(0xFFD8DBD2),
surfaceBright = Color(0xFFF7FBF1),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -364,11 +364,11 @@ val GreenTheme = Theme(
surfaceContainer = Color(0xFFECEFE6),
surfaceContainerHigh = Color(0xFFE6E9E0),
surfaceContainerHighest = Color(0xFFE0E4DB),
- surfaceTint = Color(0xFF3B6939)
+ surfaceTint = Color(0xFF4CAF50)
),
dark = ColorScheme(
- primary = Color(0xFFA1D39A),
- onPrimary = Color(0xFF0A390F),
+ primary = Color(0xFF306E32),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF235024),
onPrimaryContainer = Color(0xFFBCF0B4),
secondary = Color(0xFFBACCB3),
@@ -394,7 +394,7 @@ val GreenTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE0E4DB),
inverseOnSurface = Color(0xFF2D322C),
- inversePrimary = Color(0xFF3B6939),
+ inversePrimary = Color(0xFF4CAF50),
surfaceDim = Color(0xFF10140F),
surfaceBright = Color(0xFF363A34),
surfaceContainerLowest = Color(0xFF0B0F0A),
@@ -402,13 +402,13 @@ val GreenTheme = Theme(
surfaceContainer = Color(0xFF1D211B),
surfaceContainerHigh = Color(0xFF272B25),
surfaceContainerHighest = Color(0xFF323630),
- surfaceTint = Color(0xFFA1D39A)
+ surfaceTint = Color(0xFF306E32)
)
)
val AmberTheme = Theme(
light = ColorScheme(
- primary = Color(0xFF855318),
+ primary = Color(0xFFFF9800),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDCBE),
onPrimaryContainer = Color(0xFF2C1600),
@@ -435,7 +435,7 @@ val AmberTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF372F28),
inverseOnSurface = Color(0xFFFDEEE3),
- inversePrimary = Color(0xFFFDB975),
+ inversePrimary = Color(0xFFBA6F00),
surfaceDim = Color(0xFFE6D7CD),
surfaceBright = Color(0xFFFFF8F5),
surfaceContainerLowest = Color(0xFFFFFFFF),
@@ -443,11 +443,11 @@ val AmberTheme = Theme(
surfaceContainer = Color(0xFFFAEBE0),
surfaceContainerHigh = Color(0xFFF4E5DB),
surfaceContainerHighest = Color(0xFFEFE0D5),
- surfaceTint = Color(0xFF855318)
+ surfaceTint = Color(0xFFFF9800)
),
dark = ColorScheme(
- primary = Color(0xFFFDB975),
- onPrimary = Color(0xFF4A2800),
+ primary = Color(0xFFBA6F00),
+ onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF693C00),
onPrimaryContainer = Color(0xFFFFDCBE),
secondary = Color(0xFFE1C1A4),
@@ -473,7 +473,7 @@ val AmberTheme = Theme(
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEFE0D5),
inverseOnSurface = Color(0xFF372F28),
- inversePrimary = Color(0xFF855318),
+ inversePrimary = Color(0xFFFF9800),
surfaceDim = Color(0xFF19120C),
surfaceBright = Color(0xFF403830),
surfaceContainerLowest = Color(0xFF130D07),
@@ -481,6 +481,6 @@ val AmberTheme = Theme(
surfaceContainer = Color(0xFF261E18),
surfaceContainerHigh = Color(0xFF302822),
surfaceContainerHighest = Color(0xFF3C332C),
- surfaceTint = Color(0xFFFDB975)
+ surfaceTint = Color(0xFFBA6F00)
)
)
diff --git a/android/app/src/main/res/layout/fragment_genres.xml b/android/app/src/main/res/layout/fragment_genres.xml
index aa64eb374..65082a060 100644
--- a/android/app/src/main/res/layout/fragment_genres.xml
+++ b/android/app/src/main/res/layout/fragment_genres.xml
@@ -7,16 +7,11 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
-
+ />
= sharedPreferences.stateFlowForMappedValue(
+ key = "pref_theme",
+ default = "0",
+ getter = { key, default -> getString(key, default) },
+ mapper = { value -> Theme.entries[value.toInt()] },
+ scope = scope
+ )
+
enum class Accent {
Default,
Orange,
@@ -100,9 +114,17 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences)
sharedPreferences.put("pref_theme_accent", value.ordinal.toString())
}
get() {
- return Accent.values()[sharedPreferences.get("pref_theme_accent", "0").toInt()]
+ return Accent.entries[sharedPreferences.get("pref_theme_accent", "0").toInt()]
}
+ fun accent(scope: CoroutineScope): StateFlow = sharedPreferences.stateFlowForMappedValue(
+ key = "pref_theme_accent",
+ default = "0",
+ getter = { key, default -> getString(key, default) },
+ mapper = { value -> Accent.entries[value.toInt()] },
+ scope = scope
+ )
+
var themeExtraDark: Boolean
set(value) {
sharedPreferences.put("pref_theme_extra_dark", value)
@@ -111,6 +133,12 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences)
return sharedPreferences.get("pref_theme_extra_dark", false)
}
+ fun extraDark(scope: CoroutineScope): StateFlow = sharedPreferences.stateFlowForBoolean(
+ key = "pref_theme_extra_dark",
+ default = false,
+ scope = scope
+ )
+
var artworkWifiOnly: Boolean
set(value) {
sharedPreferences.put("artwork_wifi_only", value)
@@ -291,7 +319,7 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences)
return sharedPreferences.getString("pref_library_tabs_all", null)
?.split(",")
?.map { LibraryTab.valueOf(it) }
- ?: LibraryTab.values().toList()
+ ?: LibraryTab.entries.toList()
}
var enabledLibraryTabs: List
@@ -299,7 +327,7 @@ class GeneralPreferenceManager(private val sharedPreferences: SharedPreferences)
sharedPreferences.put("pref_library_tabs_enabled", value.joinToString(","))
}
get() {
- return sharedPreferences.getString("pref_library_tabs_enabled", LibraryTab.values().joinToString(","))
+ return sharedPreferences.getString("pref_library_tabs_enabled", LibraryTab.entries.joinToString(","))
?.split(",")
?.mapNotNull {
try {
diff --git a/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt b/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt
new file mode 100644
index 000000000..0242b8906
--- /dev/null
+++ b/android/core/src/main/java/com/simplecityapps/shuttle/persistence/stateFlowForValue.kt
@@ -0,0 +1,91 @@
+import android.content.SharedPreferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+/**
+ * Observes changes for a given preference key as a Flow.
+ *
+ * @param T The type of the default value.
+ * @param key The preference key to observe.
+ * @param default The default value if the key is not set.
+ * @param getter A lambda to retrieve the preference value.
+ * It returns a nullable value, and if null, [default] will be used.
+ */
+private fun SharedPreferences.observeValue(
+ key: String,
+ default: T,
+ getter: SharedPreferences.(String, T) -> T?
+): Flow = callbackFlow {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey ->
+ if (changedKey == key) {
+ // Use the default if the getter returns null.
+ trySend(prefs.getter(key, default) ?: default)
+ }
+ }
+ registerOnSharedPreferenceChangeListener(listener)
+ // Emit the current value immediately, using default if necessary.
+ trySend(getter(key, default) ?: default)
+ awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
+}
+
+// Specialized Flow-based observers
+fun SharedPreferences.observeInt(key: String, default: Int = 0): Flow = observeValue(key, default) { key, default -> getInt(key, default) }
+
+fun SharedPreferences.observeBoolean(key: String, default: Boolean = false): Flow = observeValue(key, default) { key, default -> getBoolean(key, default) }
+
+fun SharedPreferences.observeString(key: String, default: String? = null): Flow = observeValue(key, default) { key, default -> getString(key, default) }
+
+/**
+ * Creates a StateFlow for a preference value by mapping it from the stored type to a desired type.
+ *
+ * @param T The type of the default value.
+ * @param M The mapped type.
+ * @param key The preference key to observe.
+ * @param default The default value if the key is not set.
+ * @param getter A lambda to retrieve the preference value. If it returns null, [default] is used.
+ * @param mapper A lambda that converts the retrieved value ([T]) into the desired type ([M]).
+ * @param scope The CoroutineScope in which the StateFlow is active.
+ * @param started Defines the sharing behavior (default: WhileSubscribed with a 5000ms timeout).
+ */
+fun SharedPreferences.stateFlowForMappedValue(
+ key: String,
+ default: T,
+ getter: SharedPreferences.(String, T) -> T?,
+ mapper: (T) -> M,
+ scope: CoroutineScope,
+ started: SharingStarted = SharingStarted.WhileSubscribed(5000L)
+): StateFlow {
+ // Get the current value, using default if null, and map it.
+ val initialValue = mapper(getter(key, default) ?: default)
+ return observeValue(key, default, getter)
+ .map { value -> mapper(value ?: default) }
+ .stateIn(scope, started, initialValue)
+}
+
+// Specialized StateFlow-based observers without mapping.
+fun SharedPreferences.stateFlowFor(
+ key: String,
+ default: Int = 0,
+ scope: CoroutineScope,
+ started: SharingStarted = SharingStarted.WhileSubscribed(5000L)
+): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getInt(key, default) }, { it }, scope, started)
+
+fun SharedPreferences.stateFlowForBoolean(
+ key: String,
+ default: Boolean = false,
+ scope: CoroutineScope,
+ started: SharingStarted = SharingStarted.WhileSubscribed(5000L)
+): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getBoolean(key, default) }, { it }, scope, started)
+
+fun SharedPreferences.stateFlowForString(
+ key: String,
+ default: String? = null,
+ scope: CoroutineScope,
+ started: SharingStarted = SharingStarted.WhileSubscribed(5000L)
+): StateFlow = stateFlowForMappedValue(key, default, { key, default -> getString(key, default) }, { it }, scope, started)
diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt
new file mode 100644
index 000000000..aabdab3ae
--- /dev/null
+++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImportObserver.kt
@@ -0,0 +1,90 @@
+package com.simplecityapps.mediaprovider
+
+import com.simplecityapps.shuttle.model.MediaProviderType
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+@Singleton
+class MediaImportObserver @Inject constructor(
+ mediaImporter: MediaImporter
+) : MediaImporter.Listener {
+
+ init {
+ mediaImporter.listeners.add(this)
+ }
+
+ private val _songImportState = MutableStateFlow(SongImportState.Idle)
+ val songImportState: StateFlow = _songImportState.asStateFlow()
+
+ private val _playlistImportState = MutableStateFlow(PlaylistImportState.Idle)
+ val playlistImportState: StateFlow = _playlistImportState.asStateFlow()
+
+ override fun onStart(providerType: MediaProviderType) {
+ _songImportState.value = SongImportState.ImportProgress(
+ providerType = providerType,
+ message = null,
+ progress = null
+ )
+ }
+
+ override fun onSongImportProgress(
+ providerType: MediaProviderType,
+ message: String,
+ progress: Progress?
+ ) {
+ _songImportState.value = SongImportState.ImportProgress(providerType, message, progress)
+ }
+
+ override fun onSongImportComplete(providerType: MediaProviderType) {
+ _songImportState.value = SongImportState.ImportComplete(providerType, null)
+ }
+
+ override fun onSongImportFailed(providerType: MediaProviderType, message: String?) {
+ _songImportState.value = SongImportState.ImportComplete(providerType, message)
+ }
+
+ override fun onPlaylistImportProgress(
+ providerType: MediaProviderType,
+ message: String,
+ progress: Progress?
+ ) {
+ _playlistImportState.value = PlaylistImportState.ImportProgress(providerType, message, progress)
+ }
+
+ override fun onPlaylistImportComplete(providerType: MediaProviderType) {
+ _playlistImportState.value = PlaylistImportState.ImportComplete(providerType, null)
+ }
+
+ override fun onPlaylistImportFailed(providerType: MediaProviderType, message: String?) {
+ _playlistImportState.value = PlaylistImportState.ImportComplete(providerType, message)
+ }
+
+ override fun onAllComplete() {
+ // Anyone interested in this event could derive it by observing both state flows
+ }
+}
+
+sealed class SongImportState {
+ data object Idle : SongImportState()
+ data class ImportProgress(
+ val providerType: MediaProviderType,
+ val message: String?,
+ val progress: Progress?
+ ) : SongImportState()
+
+ data class ImportComplete(val providerType: MediaProviderType, val error: String?) : SongImportState()
+}
+
+sealed class PlaylistImportState {
+ data object Idle : PlaylistImportState()
+ data class ImportProgress(
+ val providerType: MediaProviderType,
+ val message: String?,
+ val progress: Progress?
+ ) : PlaylistImportState()
+
+ data class ImportComplete(val providerType: MediaProviderType, val error: String?) : PlaylistImportState()
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 49404203a..24cc84d9a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.aboutlibraries) apply false
+ alias(libs.plugins.detekt) apply false
}
buildscript {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e66f97ce6..83997de4a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,6 +7,8 @@ agp = "8.8.0"
billing-ktx = "7.1.1"
circleindicator = "2.1.6"
compose = "2025.01.00"
+compose-lint-checks = "1.4.2"
+detekt = "1.23.7"
ksp = "2.1.0-1.0.29"
constraintlayout = "2.2.0"
converter-moshi = "2.11.0"
@@ -107,7 +109,9 @@ androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime-ktx" }
billingclient-billingKtx = { module = "com.android.billingclient:billing-ktx", version.ref = "billing-ktx" }
+compose-lint-checks = { module = "com.slack.lint.compose:compose-lint-checks", version.ref = "compose-lint-checks" }
design-fluentSystemIcons = { module = "com.microsoft.design:fluent-system-icons", version.ref = "fluent-system-icons" }
+detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
exoplayer-core = { module = "com.github.timusus.exoplayer:exoplayer-core", version.ref = "exoplayer-shuttle" }
exoplayer-extensionFlac = { module = "com.github.timusus.exoplayer:extension-flac", version.ref = "exoplayer-shuttle" }
exoplayer-extensionOpus = { module = "com.github.timusus.exoplayer:extension-opus", version.ref = "exoplayer-shuttle" }
@@ -161,6 +165,7 @@ vdurmont-semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j"
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"}
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" }
google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt-android" }
diff --git a/support/scripts/lint b/support/scripts/lint
index 506feb02c..76eb04b54 100755
--- a/support/scripts/lint
+++ b/support/scripts/lint
@@ -7,5 +7,4 @@ VENDOR_DIR="${SUPPORT_DIR}/vendor"
PATH="${VENDOR_DIR}/ktlint:$PATH"
-ktlint -R "${VENDOR_DIR}/ktlint/ktlint-compose-0.4.22-all.jar" \
- "$@" "**.kt" "!**/generated/**" "!**/build/**"
\ No newline at end of file
+ktlint "$@" "**.kt" "!**/generated/**" "!**/build/**"
\ No newline at end of file
diff --git a/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar b/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar
deleted file mode 100644
index bf71c7e32..000000000
Binary files a/support/vendor/ktlint/ktlint-compose-0.4.22-all.jar and /dev/null differ