mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
add the browse ui: library tabs, detail screens, settings and server setup
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".SubtracksApp"
|
android:name=".SubtracksApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import com.subtracks.ui.HelloScreen
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -13,7 +13,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
HelloScreen()
|
SubtracksRoot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
package com.subtracks
|
package com.subtracks
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.subtracks.di.appModule
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
class SubtracksApp : Application()
|
class SubtracksApp : Application() {
|
||||||
|
private val http = OkHttpClient()
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
stopKoin()
|
||||||
|
startKoin {
|
||||||
|
modules(appModule(this@SubtracksApp, http))
|
||||||
|
}
|
||||||
|
SingletonImageLoader.setSafe { context ->
|
||||||
|
ImageLoader
|
||||||
|
.Builder(context)
|
||||||
|
.components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) }
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.subtracks.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.library.AlbumDetailRoute
|
||||||
|
import com.subtracks.ui.library.LibraryRoute
|
||||||
|
import com.subtracks.ui.library.PlaylistDetailRoute
|
||||||
|
import com.subtracks.ui.settings.AddSourceRoute
|
||||||
|
import com.subtracks.ui.settings.SettingsRoute
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
class RootViewModel(
|
||||||
|
sourceRepository: SourceRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val hasSource =
|
||||||
|
sourceRepository
|
||||||
|
.activeSourceId()
|
||||||
|
.map { it != null }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NAVIGATION_DURATION_MS = 260
|
||||||
|
|
||||||
|
private object Routes {
|
||||||
|
const val LIBRARY = "library"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
const val ADD_SERVER = "add-server"
|
||||||
|
const val ALBUM_DETAIL = "album/{albumId}"
|
||||||
|
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
||||||
|
|
||||||
|
fun album(id: String) = "album/${Uri.encode(id)}"
|
||||||
|
|
||||||
|
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
||||||
|
when (val hasSource = root.hasSource.collectAsStateWithLifecycle().value) {
|
||||||
|
null -> LoadingState()
|
||||||
|
false -> AddSourceRoute(onSaved = {}, onBack = null)
|
||||||
|
true -> MainNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Routes.LIBRARY,
|
||||||
|
enterTransition = {
|
||||||
|
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||||
|
fadeIn(tween(NAVIGATION_DURATION_MS))
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||||
|
fadeOut(tween(NAVIGATION_DURATION_MS))
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||||
|
fadeIn(tween(NAVIGATION_DURATION_MS))
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
|
||||||
|
fadeOut(tween(NAVIGATION_DURATION_MS))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
composable(Routes.LIBRARY) {
|
||||||
|
LibraryRoute(
|
||||||
|
onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) },
|
||||||
|
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
||||||
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.SETTINGS) {
|
||||||
|
SettingsRoute(
|
||||||
|
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.ALBUM_DETAIL,
|
||||||
|
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
AlbumDetailRoute(
|
||||||
|
albumId = entry.arguments?.getString("albumId").orEmpty(),
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.PLAYLIST_DETAIL,
|
||||||
|
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
PlaylistDetailRoute(
|
||||||
|
playlistId = entry.arguments?.getString("playlistId").orEmpty(),
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.ADD_SERVER) {
|
||||||
|
AddSourceRoute(
|
||||||
|
onSaved = { navController.popBackStack() },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.subtracks.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HelloScreen(modifier: Modifier = Modifier) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Subtracks",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Kotlin / Jetpack Compose rewrite",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun HelloScreenPreview() {
|
|
||||||
SubtracksTheme {
|
|
||||||
HelloScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.LocalPlatformContext
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CoverArt(
|
||||||
|
ref: CoverArtRef?,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
|
Text(
|
||||||
|
text = name.trim().take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
if (ref != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest
|
||||||
|
.Builder(LocalPlatformContext.current)
|
||||||
|
.data(ref.url)
|
||||||
|
.memoryCacheKey(ref.cacheKey)
|
||||||
|
.diskCacheKey(ref.cacheKey)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingState(modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyState(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumDetailRoute(
|
||||||
|
albumId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
|
) {
|
||||||
|
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
AlbumDetailScreen(
|
||||||
|
album = album,
|
||||||
|
songs = songs,
|
||||||
|
coverArt = viewModel::coverArt,
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AlbumDetailScreen(
|
||||||
|
album: Album?,
|
||||||
|
songs: List<Song>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(album?.name.orEmpty(), maxLines = 1) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(album?.coverArt, false),
|
||||||
|
name = album?.name.orEmpty(),
|
||||||
|
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)),
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = album?.albumArtist.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(album?.year?.takeIf { it > 0 }, "${songs.size} songs").joinToString(" • "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(songs, key = { it.id }) { song -> SongRow(song) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AlbumDetailViewModel(
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
albumId: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||||
|
|
||||||
|
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
|
||||||
|
|
||||||
|
fun coverArt(
|
||||||
|
coverArt: String?,
|
||||||
|
thumbnail: Boolean,
|
||||||
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.EmptyState
|
||||||
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
|
||||||
|
const val ALBUM_COVER_TAG = "album-cover"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumsContent(
|
||||||
|
items: LazyPagingItems<Album>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
bottomInset: Dp,
|
||||||
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
|
LoadingState(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.itemCount == 0 -> {
|
||||||
|
EmptyState("No albums yet.\nSync with your server to fill your library.", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = bottomInset + 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
|
val album = items[index]
|
||||||
|
if (album != null) {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(album.coverArt, true),
|
||||||
|
name = album.name,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.clickable { onAlbumClick(album) }
|
||||||
|
.testTag(ALBUM_COVER_TAG),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.EmptyState
|
||||||
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistsContent(
|
||||||
|
items: LazyPagingItems<Artist>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
bottomInset: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
|
LoadingState(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.itemCount == 0 -> {
|
||||||
|
EmptyState("No artists yet.", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
|
) {
|
||||||
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
|
val artist = items[index]
|
||||||
|
if (artist != null) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(artist.name) },
|
||||||
|
supportingContent = { Text("${artist.albumCount} albums") },
|
||||||
|
leadingContent = {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(artist.coverArt, true),
|
||||||
|
name = artist.name,
|
||||||
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
|
||||||
|
import androidx.compose.material.icons.rounded.Album
|
||||||
|
import androidx.compose.material.icons.rounded.MusicNote
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material.icons.rounded.Sync
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.boundsInParent
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
enum class LibraryTab(
|
||||||
|
val label: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
) {
|
||||||
|
Albums("Albums", Icons.Rounded.Album),
|
||||||
|
Artists("Artists", Icons.Rounded.Person),
|
||||||
|
Songs("Songs", Icons.Rounded.MusicNote),
|
||||||
|
Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay),
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LibraryRoute(
|
||||||
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
viewModel: LibraryViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||||
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
LibraryScreen(
|
||||||
|
selectedTab = selectedTab,
|
||||||
|
onTabSelected = { selectedTab = it },
|
||||||
|
syncing = syncing,
|
||||||
|
albums = viewModel.albums.collectAsLazyPagingItems(),
|
||||||
|
artists = viewModel.artists.collectAsLazyPagingItems(),
|
||||||
|
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||||
|
playlists = viewModel.playlists.collectAsLazyPagingItems(),
|
||||||
|
coverArt = viewModel::coverArt,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onSync = viewModel::sync,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(
|
||||||
|
selectedTab: LibraryTab,
|
||||||
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
|
albums: LazyPagingItems<Album>,
|
||||||
|
artists: LazyPagingItems<Artist>,
|
||||||
|
songs: LazyPagingItems<SongListItem>,
|
||||||
|
playlists: LazyPagingItems<Playlist>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
syncing: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pagerState =
|
||||||
|
rememberPagerState(
|
||||||
|
initialPage = selectedTab.ordinal,
|
||||||
|
pageCount = { LibraryTab.entries.size },
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
|
onTabSelected(LibraryTab.entries[page])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(selectedTab) {
|
||||||
|
if (pagerState.currentPage != selectedTab.ordinal) {
|
||||||
|
pagerState.animateScrollToPage(selectedTab.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val titleHeight =
|
||||||
|
with(density) {
|
||||||
|
MaterialTheme.typography.headlineLarge.lineHeight
|
||||||
|
.toDp()
|
||||||
|
} + 12.dp
|
||||||
|
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||||
|
SideEffect { scrollBehavior.state.heightOffsetLimit = -with(density) { titleHeight.toPx() } }
|
||||||
|
val titleFraction = scrollBehavior.state.collapsedFraction
|
||||||
|
|
||||||
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
|
val bottomInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = statusBarTop),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(titleHeight * (1f - titleFraction))
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedTab.label,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.offset(y = -(titleHeight * titleFraction))
|
||||||
|
.wrapContentHeight(unbounded = true)
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LibraryTabs(
|
||||||
|
pagerState = pagerState,
|
||||||
|
onTabSelected = onTabSelected,
|
||||||
|
syncing = syncing,
|
||||||
|
onSync = onSync,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
) { page ->
|
||||||
|
when (LibraryTab.entries[page]) {
|
||||||
|
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick)
|
||||||
|
LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset)
|
||||||
|
LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset)
|
||||||
|
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun LibraryTabs(
|
||||||
|
pagerState: PagerState,
|
||||||
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
|
syncing: Boolean,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val iconFadeThreshold = 0.65f
|
||||||
|
val indicatorStretch = 18.dp
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||||
|
val page = pagerState.currentPage
|
||||||
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
|
val position = page + fraction
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
) {
|
||||||
|
bounds[page]?.let { current ->
|
||||||
|
val neighbour = bounds[if (fraction >= 0f) page + 1 else page - 1]
|
||||||
|
val step = if (neighbour != null) abs(fraction) else 0f
|
||||||
|
val left = current.left + ((neighbour?.left ?: current.left) - current.left) * step
|
||||||
|
val right = current.right + ((neighbour?.right ?: current.right) - current.right) * step
|
||||||
|
val stretch = with(density) { indicatorStretch.toPx() } * abs(fraction) / 2f
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.offset { IntOffset((left - stretch).roundToInt(), current.top.roundToInt()) }
|
||||||
|
.width(with(density) { (right - left + stretch * 2f).toDp() })
|
||||||
|
.height(with(density) { current.height.toDp() })
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.onBackground),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||||
|
TabButton(
|
||||||
|
tab = tab,
|
||||||
|
progress =
|
||||||
|
((iconFadeThreshold - abs(position - index)) / iconFadeThreshold)
|
||||||
|
.coerceIn(0f, 1f),
|
||||||
|
onClick = { onTabSelected(tab) },
|
||||||
|
modifier =
|
||||||
|
Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
bounds[index] = coordinates.boundsInParent()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(end = 4.dp).size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSync) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Sync,
|
||||||
|
contentDescription = "Sync",
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Settings,
|
||||||
|
contentDescription = "Settings",
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TabButton(
|
||||||
|
tab: LibraryTab,
|
||||||
|
progress: Float,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val content = lerp(MaterialTheme.colorScheme.onBackground, MaterialTheme.colorScheme.background, progress)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = tab.icon,
|
||||||
|
contentDescription = tab.label,
|
||||||
|
tint = content,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import androidx.paging.cachedIn
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncStatus
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LibraryViewModel(
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
userPreferences: UserPreferences,
|
||||||
|
) : ViewModel() {
|
||||||
|
val albums: Flow<PagingData<Album>> =
|
||||||
|
libraryRepository.activeSourceId
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { sourceId ->
|
||||||
|
userPreferences.albumSort.flatMapLatest { sort -> libraryRepository.albums(sourceId, sort) }
|
||||||
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val artists: Flow<PagingData<Artist>> =
|
||||||
|
libraryRepository.activeSourceId
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { libraryRepository.artists(it) }
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val songs: Flow<PagingData<SongListItem>> =
|
||||||
|
libraryRepository.activeSourceId
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { libraryRepository.songs(it) }
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val playlists: Flow<PagingData<Playlist>> =
|
||||||
|
libraryRepository.activeSourceId
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { libraryRepository.playlists(it) }
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
val syncing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it == SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
fun coverArt(
|
||||||
|
coverArt: String?,
|
||||||
|
thumbnail: Boolean,
|
||||||
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun sync() = syncManager.requestSync()
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistDetailRoute(
|
||||||
|
playlistId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
|
) {
|
||||||
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
PlaylistDetailScreen(
|
||||||
|
playlist = playlist,
|
||||||
|
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||||
|
coverArt = viewModel::coverArt,
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PlaylistDetailScreen(
|
||||||
|
playlist: Playlist?,
|
||||||
|
songs: LazyPagingItems<SongListItem>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(playlist?.name.orEmpty(), maxLines = 1) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(playlist?.coverArt, false),
|
||||||
|
name = playlist?.name.orEmpty(),
|
||||||
|
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)),
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(playlist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
text = "${playlist?.songCount ?: 0} songs",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
|
val item = songs[index]
|
||||||
|
if (item != null) {
|
||||||
|
SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import androidx.paging.cachedIn
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class PlaylistDetailViewModel(
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
playlistId: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||||
|
|
||||||
|
val songs: Flow<PagingData<SongListItem>> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
fun coverArt(
|
||||||
|
coverArt: String?,
|
||||||
|
thumbnail: Boolean,
|
||||||
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.EmptyState
|
||||||
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistsContent(
|
||||||
|
items: LazyPagingItems<Playlist>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
bottomInset: Dp,
|
||||||
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
|
LoadingState(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.itemCount == 0 -> {
|
||||||
|
EmptyState("No playlists yet.", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
|
) {
|
||||||
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
|
val playlist = items[index]
|
||||||
|
if (playlist != null) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(playlist.name) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(playlist.comment?.takeIf { it.isNotBlank() } ?: "${playlist.songCount} songs")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(playlist.coverArt, true),
|
||||||
|
name = playlist.name,
|
||||||
|
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onPlaylistClick(playlist) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongListItem
|
||||||
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.EmptyState
|
||||||
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongsContent(
|
||||||
|
items: LazyPagingItems<SongListItem>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
bottomInset: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
|
LoadingState(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.itemCount == 0 -> {
|
||||||
|
EmptyState("No songs yet.", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = bottomInset),
|
||||||
|
) {
|
||||||
|
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
|
||||||
|
val item = items[index]
|
||||||
|
if (item != null) {
|
||||||
|
SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SongRow(
|
||||||
|
song: Song,
|
||||||
|
coverArtId: String? = null,
|
||||||
|
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
leadingContent =
|
||||||
|
if (coverArt != null) {
|
||||||
|
{
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(coverArtId, true),
|
||||||
|
name = song.album ?: song.title,
|
||||||
|
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
headlineContent = { Text(song.title) },
|
||||||
|
supportingContent = {
|
||||||
|
val subtitle = listOfNotNull(song.artist, song.album).joinToString(" • ")
|
||||||
|
if (subtitle.isNotEmpty()) Text(subtitle)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddSourceRoute(
|
||||||
|
onSaved: () -> Unit,
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
viewModel: AddSourceViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
AddSourceScreen(
|
||||||
|
state = state,
|
||||||
|
onNameChange = viewModel::setName,
|
||||||
|
onAddressChange = viewModel::setAddress,
|
||||||
|
onUsernameChange = viewModel::setUsername,
|
||||||
|
onPasswordChange = viewModel::setPassword,
|
||||||
|
onTokenAuthChange = viewModel::setTokenAuth,
|
||||||
|
onTest = viewModel::testConnection,
|
||||||
|
onSave = { viewModel.save(onSaved) },
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AddSourceScreen(
|
||||||
|
state: AddSourceState,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onAddressChange: (String) -> Unit,
|
||||||
|
onUsernameChange: (String) -> Unit,
|
||||||
|
onPasswordChange: (String) -> Unit,
|
||||||
|
onTokenAuthChange: (Boolean) -> Unit,
|
||||||
|
onTest: () -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onBack: (() -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Add server") },
|
||||||
|
navigationIcon = {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (state.busy) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onTest, enabled = !state.busy) {
|
||||||
|
Text("Test connection")
|
||||||
|
}
|
||||||
|
Button(onClick = onSave, enabled = !state.busy) {
|
||||||
|
Text("Save and sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.address,
|
||||||
|
onValueChange = onAddressChange,
|
||||||
|
label = { Text("Server address") },
|
||||||
|
placeholder = { Text("https://music.example.com") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = onUsernameChange,
|
||||||
|
label = { Text("Username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = onPasswordChange,
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Token authentication")
|
||||||
|
Text(
|
||||||
|
"Recommended. Disable only for servers that reject the salted token scheme.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = state.useTokenAuth, onCheckedChange = onTokenAuthChange)
|
||||||
|
}
|
||||||
|
if (state.message != null) {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (state.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AddSourceState(
|
||||||
|
val name: String = "",
|
||||||
|
val address: String = "",
|
||||||
|
val username: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val useTokenAuth: Boolean = true,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AddSourceViewModel(
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val syncManager: SyncManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(AddSourceState())
|
||||||
|
val state: StateFlow<AddSourceState> = _state
|
||||||
|
|
||||||
|
fun setName(value: String) = _state.update { it.copy(name = value) }
|
||||||
|
|
||||||
|
fun setAddress(value: String) = _state.update { it.copy(address = value) }
|
||||||
|
|
||||||
|
fun setUsername(value: String) = _state.update { it.copy(username = value) }
|
||||||
|
|
||||||
|
fun setPassword(value: String) = _state.update { it.copy(password = value) }
|
||||||
|
|
||||||
|
fun setTokenAuth(value: Boolean) = _state.update { it.copy(useTokenAuth = value) }
|
||||||
|
|
||||||
|
fun testConnection() {
|
||||||
|
_state.update { it.copy(busy = true, message = null) }
|
||||||
|
val current = _state.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = sourceRepository.ping(current.address, current.username, current.password, current.useTokenAuth)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
busy = false,
|
||||||
|
message = result.fold({ "Connection OK" }, { error -> "Failed: ${error.message}" }),
|
||||||
|
isError = result.isFailure,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onSaved: () -> Unit) {
|
||||||
|
val current = _state.value
|
||||||
|
if (current.name.isBlank() || current.address.isBlank()) {
|
||||||
|
_state.update { it.copy(message = "Name and address are required", isError = true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.update { it.copy(busy = true, message = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
sourceRepository.addSource(
|
||||||
|
name = current.name,
|
||||||
|
address = current.address,
|
||||||
|
username = current.username,
|
||||||
|
password = current.password,
|
||||||
|
useTokenAuth = current.useTokenAuth,
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
syncManager.requestSync()
|
||||||
|
_state.update { it.copy(busy = false) }
|
||||||
|
onSaved()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(busy = false, message = "Could not save: ${error.message}", isError = true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsRoute(
|
||||||
|
onAddServer: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val sources by viewModel.sources.collectAsStateWithLifecycle()
|
||||||
|
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
|
||||||
|
SettingsScreen(
|
||||||
|
sources = sources,
|
||||||
|
activeSourceId = activeSourceId,
|
||||||
|
onSelectSource = viewModel::selectSource,
|
||||||
|
onDeleteSource = viewModel::deleteSource,
|
||||||
|
onAddServer = onAddServer,
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
sources: List<Source>,
|
||||||
|
activeSourceId: Long?,
|
||||||
|
onSelectSource: (Long) -> Unit,
|
||||||
|
onDeleteSource: (Long) -> Unit,
|
||||||
|
onAddServer: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Settings") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
item { SectionHeader("Servers") }
|
||||||
|
items(sources.size, key = { sources[it].id }) { index ->
|
||||||
|
val source = sources[index]
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(source.name) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(source.address, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = source.id == activeSourceId,
|
||||||
|
onClick = { onSelectSource(source.id) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = { onDeleteSource(source.id) }) {
|
||||||
|
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
Button(onClick = onAddServer) {
|
||||||
|
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Add server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SettingsViewModel(
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val sources: StateFlow<List<Source>> =
|
||||||
|
sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
val activeSourceId: StateFlow<Long?> =
|
||||||
|
sourceRepository.activeSourceId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
fun selectSource(id: Long) {
|
||||||
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSource(id: Long) {
|
||||||
|
viewModelScope.launch { sourceRepository.deleteSource(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ package com.subtracks.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val Black = Color(0xFF000000)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val NearBlack = Color(0xFF0A0A0A)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val DarkGrey = Color(0xFF1A1A1A)
|
||||||
|
val MidGrey = Color(0xFF2C2C2C)
|
||||||
val Purple40 = Color(0xFF6650A4)
|
val LightGrey = Color(0xFF9E9E9E)
|
||||||
val PurpleGrey40 = Color(0xFF625B71)
|
val White = Color(0xFFFFFFFF)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
|
||||||
|
|||||||
@@ -4,16 +4,37 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
private val SubtracksDarkColorScheme = darkColorScheme(
|
private val SubtracksColorScheme =
|
||||||
primary = Purple80,
|
darkColorScheme(
|
||||||
secondary = PurpleGrey80,
|
primary = White,
|
||||||
tertiary = Pink80,
|
onPrimary = Black,
|
||||||
)
|
primaryContainer = White,
|
||||||
|
onPrimaryContainer = Black,
|
||||||
|
secondary = White,
|
||||||
|
onSecondary = Black,
|
||||||
|
secondaryContainer = White,
|
||||||
|
onSecondaryContainer = Black,
|
||||||
|
tertiary = White,
|
||||||
|
onTertiary = Black,
|
||||||
|
background = Black,
|
||||||
|
onBackground = White,
|
||||||
|
surface = Black,
|
||||||
|
onSurface = White,
|
||||||
|
surfaceVariant = DarkGrey,
|
||||||
|
onSurfaceVariant = LightGrey,
|
||||||
|
surfaceContainerLowest = Black,
|
||||||
|
surfaceContainerLow = NearBlack,
|
||||||
|
surfaceContainer = NearBlack,
|
||||||
|
surfaceContainerHigh = DarkGrey,
|
||||||
|
surfaceContainerHighest = MidGrey,
|
||||||
|
outline = MidGrey,
|
||||||
|
outlineVariant = DarkGrey,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SubtracksTheme(content: @Composable () -> Unit) {
|
fun SubtracksTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = SubtracksDarkColorScheme,
|
colorScheme = SubtracksColorScheme,
|
||||||
typography = SubtracksTypography,
|
typography = SubtracksTypography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package com.subtracks.ui.theme
|
|||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
val SubtracksTypography = Typography().let { base ->
|
val SubtracksTypography =
|
||||||
|
Typography().let { base ->
|
||||||
base.copy(
|
base.copy(
|
||||||
headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800),
|
headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800),
|
||||||
headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700),
|
headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700),
|
||||||
headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600),
|
headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user