diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecb17931..36330217 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "org.grakovne.lissen" minSdk = 28 targetSdk = 35 - versionCode = 55 - versionName = "1.1.24" + versionCode = 56 + versionName = "1.1.25" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt b/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt index f0958f6a..c89fd139 100644 --- a/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt +++ b/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt @@ -3,12 +3,8 @@ package org.grakovne.lissen.common import android.content.Context import android.content.Context.CONNECTIVITY_SERVICE import android.net.ConnectivityManager -import android.net.Network import android.net.NetworkCapabilities import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @@ -17,34 +13,14 @@ class NetworkQualityService @Inject constructor( @ApplicationContext private val context: Context, ) { - private val connectivityManager = - context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - - private val _networkStatus = MutableStateFlow(false) - val networkStatus: StateFlow = _networkStatus.asStateFlow() - - init { - registerNetworkCallback() - } + private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager fun isNetworkAvailable(): Boolean { val network = connectivityManager.activeNetwork ?: return false - val networkCapabilities = - connectivityManager.getNetworkCapabilities(network) ?: return false - return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - } - - private fun registerNetworkCallback() { - val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - _networkStatus.value = true - } - override fun onLost(network: Network) { - _networkStatus.value = false - } - } - - connectivityManager.registerDefaultNetworkCallback(networkCallback) + val networkCapabilities = connectivityManager + .getNetworkCapabilities(network) + ?: return false + return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt index 307820d0..fe04f795 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt @@ -1,6 +1,13 @@ package org.grakovne.lissen.ui.navigation import android.annotation.SuppressLint +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -41,6 +48,26 @@ fun AppNavHost( else -> "login_screen" } + val enterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(), + ) + fadeIn(animationSpec = tween()) + + val exitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(), + ) + fadeOut(animationSpec = tween()) + + val popEnterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(), + ) + fadeIn(animationSpec = tween()) + + val popExitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(), + ) + fadeOut(animationSpec = tween()) + Scaffold(modifier = Modifier.fillMaxSize()) { _ -> NavHost( navController = navController, @@ -55,11 +82,15 @@ fun AppNavHost( } composable( - "player_screen/{bookId}?bookTitle={bookTitle}", + route = "player_screen/{bookId}?bookTitle={bookTitle}", arguments = listOf( navArgument("bookId") { type = NavType.StringType }, navArgument("bookTitle") { type = NavType.StringType; nullable = true }, ), + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, ) { navigationStack -> val bookId = navigationStack.arguments?.getString("bookId") ?: return@composable val bookTitle = navigationStack.arguments?.getString("bookTitle") ?: "" @@ -72,11 +103,23 @@ fun AppNavHost( ) } - composable("login_screen") { + composable( + route = "login_screen", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { LoginScreen(navigationService) } - composable("settings_screen") { + composable( + route = "settings_screen", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { SettingsScreen( onBack = { if (navController.previousBackStackEntry != null) { @@ -87,7 +130,13 @@ fun AppNavHost( ) } - composable("settings_screen/custom_headers") { + composable( + route = "settings_screen/custom_headers", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { CustomHeadersSettingsScreen( onBack = { if (navController.previousBackStackEntry != null) { diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt index d2e0a375..e43789b0 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -30,15 +30,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext @@ -52,7 +51,6 @@ import androidx.paging.compose.collectAsLazyPagingItems import coil.ImageLoader import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.grakovne.lissen.R import org.grakovne.lissen.channel.common.LibraryType @@ -72,6 +70,7 @@ import org.grakovne.lissen.ui.screens.library.composables.placeholder.RecentBook import org.grakovne.lissen.viewmodel.ContentCachingModelView import org.grakovne.lissen.viewmodel.LibraryViewModel import org.grakovne.lissen.viewmodel.PlayerViewModel +import org.grakovne.lissen.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable @@ -79,6 +78,7 @@ fun LibraryScreen( navController: AppNavigationService, libraryViewModel: LibraryViewModel = hiltViewModel(), playerViewModel: PlayerViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), contentCachingModelView: ContentCachingModelView = hiltViewModel(), imageLoader: ImageLoader, networkQualityService: NetworkQualityService, @@ -89,7 +89,7 @@ fun LibraryScreen( val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) - val networkStatus by networkQualityService.networkStatus.collectAsState() + var currentLibraryId by rememberSaveable { mutableStateOf("") } var pullRefreshing by remember { mutableStateOf(false) } val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) val searchRequested by libraryViewModel.searchRequested.observeAsState(false) @@ -131,12 +131,6 @@ fun LibraryScreen( } } - LaunchedEffect(Unit) { - snapshotFlow { networkStatus } - .distinctUntilChanged() - .collect { _ -> refreshContent(false) } - } - LaunchedEffect(preparingError) { if (preparingError) { playerViewModel.clearPlayingBook() @@ -158,15 +152,18 @@ fun LibraryScreen( val playingBook by playerViewModel.book.observeAsState() val context = LocalContext.current - fun showRecent(): Boolean { - val fetchAvailable = networkStatus || contentCachingModelView.localCacheUsing() + fun isRecentVisible(): Boolean { + val fetchAvailable = networkQualityService.isNetworkAvailable() || contentCachingModelView.localCacheUsing() val hasContent = recentBooks.isEmpty().not() return !searchRequested && hasContent && fetchAvailable } LaunchedEffect(Unit) { - libraryViewModel.refreshRecentListening() - libraryViewModel.refreshLibrary() + if (library.itemCount == 0 || currentLibraryId != settingsViewModel.fetchPreferredLibraryId()) { + libraryViewModel.refreshRecentListening() + libraryViewModel.refreshLibrary() + currentLibraryId = settingsViewModel.fetchPreferredLibraryId() + } } LaunchedEffect(searchRequested) { @@ -187,7 +184,7 @@ fun LibraryScreen( val navBarTitle by remember { derivedStateOf { - val showRecent = showRecent() + val showRecent = isRecentVisible() val recentBlockVisible = libraryListState.layoutInfo.visibleItemsInfo.firstOrNull()?.key == "recent_books" when { @@ -267,7 +264,7 @@ fun LibraryScreen( contentPadding = PaddingValues(horizontal = 16.dp), ) { item(key = "recent_books") { - val showRecent = showRecent() + val showRecent = isRecentVisible() when { isPlaceholderRequired -> { @@ -289,7 +286,7 @@ fun LibraryScreen( } item(key = "library_title") { - if (!searchRequested && showRecent()) { + if (!searchRequested && isRecentVisible()) { AnimatedContent( targetState = navBarTitle, transitionSpec = { diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt index 75989740..87a50cce 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt @@ -1,5 +1,4 @@ package org.grakovne.lissen.ui.screens.player - import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -49,6 +48,7 @@ import org.grakovne.lissen.ui.screens.player.composable.PlayingQueueComposable import org.grakovne.lissen.ui.screens.player.composable.TrackControlComposable import org.grakovne.lissen.ui.screens.player.composable.TrackDetailsComposable import org.grakovne.lissen.ui.screens.player.composable.placeholder.PlayingQueuePlaceholderComposable +import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackControlPlaceholderComposable import org.grakovne.lissen.ui.screens.player.composable.placeholder.TrackDetailsPlaceholderComposable import org.grakovne.lissen.ui.screens.player.composable.provideNowPlayingTitle import org.grakovne.lissen.viewmodel.ContentCachingModelView @@ -196,10 +196,16 @@ fun PlayerScreen( ) } - TrackControlComposable( - viewModel = playerViewModel, - modifier = Modifier, - ) + if (!isPlaybackReady) { + TrackControlPlaceholderComposable( + modifier = Modifier, + ) + } else { + TrackControlComposable( + viewModel = playerViewModel, + modifier = Modifier, + ) + } } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt index 92767f11..31d6ce4f 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt @@ -43,7 +43,6 @@ fun TrackControlComposable( modifier: Modifier = Modifier, ) { val isPlaying by viewModel.isPlaying.observeAsState(false) - val playbackReady by viewModel.isPlaybackReady.observeAsState(false) val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) val currentTrackPosition by viewModel.currentChapterPosition.observeAsState(0.0) val currentTrackDuration by viewModel.currentChapterDuration.observeAsState(0.0) @@ -55,7 +54,7 @@ fun TrackControlComposable( var isDragging by remember { mutableStateOf(false) } LaunchedEffect(currentTrackPosition, currentTrackIndex, currentTrackDuration) { - if (playbackReady && !isDragging) { + if (!isDragging) { sliderPosition = currentTrackPosition } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt new file mode 100644 index 00000000..f9f3544b --- /dev/null +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt @@ -0,0 +1,150 @@ +package org.grakovne.lissen.ui.screens.player.composable.placeholder + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Forward30 +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material.icons.rounded.Replay10 +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.grakovne.lissen.ui.extensions.formatFully + +@Composable +fun TrackControlPlaceholderComposable( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = 0f, + onValueChange = { newPosition -> + }, + onValueChangeFinished = { + }, + valueRange = 0f..100f, + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = 0.formatFully(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + Text( + text = 0.formatFully(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + + Spacer(modifier = Modifier.height(64.dp)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + }, + enabled = true, + ) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Previous Track", + tint = colorScheme.onBackground, + modifier = Modifier.size(36.dp), + ) + } + + IconButton( + onClick = { }, + ) { + Icon( + imageVector = Icons.Rounded.Replay10, + contentDescription = "Rewind", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = { }, + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = "Play / Pause", + tint = colorScheme.primary, + modifier = Modifier.fillMaxSize(), + ) + } + + IconButton( + onClick = { }, + ) { + Icon( + imageVector = Icons.Outlined.Forward30, + contentDescription = "Forward", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = {}, + ) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next Track", + modifier = Modifier.size(36.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index 2bef958b..c76c0657 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -80,6 +80,10 @@ class SettingsViewModel @Inject constructor( } } + fun fetchPreferredLibraryId(): String { + return preferences.getPreferredLibrary()?.id ?: "" + } + fun preferLibrary(library: Library) { _preferredLibrary.value = library preferences.savePreferredLibrary(library)