diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 252f4f01..1f697d0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + ImageLoader + .Builder(context) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) } + .build() + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt new file mode 100644 index 00000000..ac31d9ca --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -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() }, + ) + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/HelloScreen.kt b/app/src/main/kotlin/com/subtracks/ui/HelloScreen.kt deleted file mode 100644 index ae745c44..00000000 --- a/app/src/main/kotlin/com/subtracks/ui/HelloScreen.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt new file mode 100644 index 00000000..422363e5 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -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, + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt new file mode 100644 index 00000000..698d056c --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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, + 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) } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt new file mode 100644 index 00000000..e9259280 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -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 = sourceId.flatMapLatest { libraryRepository.album(it, albumId) } + + val songs: Flow> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) } + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt new file mode 100644 index 00000000..0717cd69 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -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, + 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), + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt new file mode 100644 index 00000000..7cb6ca98 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -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, + 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), + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt new file mode 100644 index 00000000..9b81686f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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, + artists: LazyPagingItems, + songs: LazyPagingItems, + playlists: LazyPagingItems, + 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() } + 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), + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt new file mode 100644 index 00000000..45755730 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -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> = + libraryRepository.activeSourceId + .filterNotNull() + .flatMapLatest { sourceId -> + userPreferences.albumSort.flatMapLatest { sort -> libraryRepository.albums(sourceId, sort) } + }.cachedIn(viewModelScope) + + val artists: Flow> = + libraryRepository.activeSourceId + .filterNotNull() + .flatMapLatest { libraryRepository.artists(it) } + .cachedIn(viewModelScope) + + val songs: Flow> = + libraryRepository.activeSourceId + .filterNotNull() + .flatMapLatest { libraryRepository.songs(it) } + .cachedIn(viewModelScope) + + val playlists: Flow> = + libraryRepository.activeSourceId + .filterNotNull() + .flatMapLatest { libraryRepository.playlists(it) } + .cachedIn(viewModelScope) + + val syncing: StateFlow = + 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() +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt new file mode 100644 index 00000000..ece74031 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -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, + 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) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt new file mode 100644 index 00000000..6b980a63 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -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 = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) } + + val songs: Flow> = + sourceId + .flatMapLatest { libraryRepository.playlistSongs(it, playlistId) } + .cachedIn(viewModelScope) + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt new file mode 100644 index 00000000..8e3eabd9 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -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, + 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), + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt new file mode 100644 index 00000000..5a30164b --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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, + 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), + ) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt new file mode 100644 index 00000000..d1279631 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt new file mode 100644 index 00000000..aba01642 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceViewModel.kt @@ -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 = _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) + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt new file mode 100644 index 00000000..5ee0e674 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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, + 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), + ) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..98cf599a --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -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> = + sourceRepository.sources().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val activeSourceId: StateFlow = + 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) } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt b/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt index 8d2eb1a9..91383b1f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt @@ -2,10 +2,9 @@ package com.subtracks.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650A4) -val PurpleGrey40 = Color(0xFF625B71) -val Pink40 = Color(0xFF7D5260) +val Black = Color(0xFF000000) +val NearBlack = Color(0xFF0A0A0A) +val DarkGrey = Color(0xFF1A1A1A) +val MidGrey = Color(0xFF2C2C2C) +val LightGrey = Color(0xFF9E9E9E) +val White = Color(0xFFFFFFFF) diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt index 573e2e08..12c66b45 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt @@ -4,16 +4,37 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable -private val SubtracksDarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, -) +private val SubtracksColorScheme = + darkColorScheme( + primary = White, + 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 fun SubtracksTheme(content: @Composable () -> Unit) { MaterialTheme( - colorScheme = SubtracksDarkColorScheme, + colorScheme = SubtracksColorScheme, typography = SubtracksTypography, content = content, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/Type.kt b/app/src/main/kotlin/com/subtracks/ui/theme/Type.kt index 7907a208..75d1bd00 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/Type.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/Type.kt @@ -3,10 +3,11 @@ package com.subtracks.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.font.FontWeight -val SubtracksTypography = Typography().let { base -> - base.copy( - headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800), - headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700), - headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600), - ) -} +val SubtracksTypography = + Typography().let { base -> + base.copy( + headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.W800), + headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.W700), + headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.W600), + ) + }