diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2bae5afc2..94e02b614 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -187,7 +187,6 @@ dependencies { implementation(androidx.appcompat) implementation(androidx.biometricktx) implementation(androidx.constraintlayout) - implementation(androidx.coordinatorlayout) implementation(androidx.corektx) implementation(androidx.splashscreen) implementation(androidx.recyclerview) @@ -236,7 +235,6 @@ dependencies { // UI libraries implementation(libs.material) implementation(libs.flexible.adapter.core) - implementation(libs.flexible.adapter.ui) implementation(libs.photoview) implementation(libs.directionalviewpager) { exclude(group = "androidx.viewpager", module = "viewpager") @@ -245,7 +243,6 @@ dependencies { implementation(libs.bundles.richtext) implementation(libs.aboutLibraries.compose) implementation(libs.bundles.voyager) - implementation(libs.compose.cascade) implementation(libs.compose.materialmotion) implementation(libs.compose.simpleicons) diff --git a/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt index 08a200b50..cacb0fe20 100644 --- a/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt +++ b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt @@ -4,13 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.with -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -20,7 +14,7 @@ import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.ScreenTransition +import eu.kanade.presentation.util.ScreenTransition import eu.kanade.presentation.util.isTabletUi import tachiyomi.presentation.core.components.AdaptiveSheet as AdaptiveSheetImpl @@ -43,7 +37,7 @@ fun NavigatorAdaptiveSheet( ScreenTransition( navigator = sheetNavigator, transition = { - fadeIn(animationSpec = tween(220, delayMillis = 90)) with + fadeIn(animationSpec = tween(220, delayMillis = 90)) togetherWith fadeOut(animationSpec = tween(90)) }, ) @@ -79,15 +73,13 @@ fun AdaptiveSheet( tonalElevation: Dp = 1.dp, enableSwipeDismiss: Boolean = true, onDismissRequest: () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable () -> Unit, ) { val isTabletUi = isTabletUi() + Dialog( onDismissRequest = onDismissRequest, - properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), + properties = dialogProperties, ) { AdaptiveSheetImpl( isTabletUi = isTabletUi, @@ -95,12 +87,12 @@ fun AdaptiveSheet( enableSwipeDismiss = enableSwipeDismiss, onDismissRequest = onDismissRequest, ) { - val contentPadding = if (isTabletUi) { - PaddingValues() - } else { - WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() - } - content(contentPadding) + content() } } } + +private val dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, +) diff --git a/app/src/main/java/eu/kanade/presentation/components/AppBar.kt b/app/src/main/java/eu/kanade/presentation/components/AppBar.kt index 0f7676318..7c995608b 100644 --- a/app/src/main/java/eu/kanade/presentation/components/AppBar.kt +++ b/app/src/main/java/eu/kanade/presentation/components/AppBar.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltipBox import androidx.compose.material3.Text @@ -37,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager @@ -210,10 +212,11 @@ fun AppBarActions( IconButton( onClick = it.onClick, enabled = it.enabled, - modifier = Modifier.tooltipAnchor(), + modifier = Modifier.tooltipTrigger(), ) { Icon( imageVector = it.icon, + tint = it.iconTint ?: LocalContentColor.current, contentDescription = it.title, ) } @@ -227,7 +230,7 @@ fun AppBarActions( ) { IconButton( onClick = { showMenu = !showMenu }, - modifier = Modifier.tooltipAnchor(), + modifier = Modifier.tooltipTrigger(), ) { Icon( Icons.Outlined.MoreVert, @@ -348,7 +351,7 @@ fun SearchToolbar( ) { IconButton( onClick = onClick, - modifier = Modifier.tooltipAnchor(), + modifier = Modifier.tooltipTrigger(), ) { Icon( Icons.Outlined.Search, @@ -365,7 +368,7 @@ fun SearchToolbar( onClick() focusRequester.requestFocus() }, - modifier = Modifier.tooltipAnchor(), + modifier = Modifier.tooltipTrigger(), ) { Icon( Icons.Outlined.Close, @@ -390,6 +393,7 @@ sealed interface AppBar { data class Action( val title: String, val icon: ImageVector, + val iconTint: Color? = null, val onClick: () -> Unit, val enabled: Boolean = true, ) : AppBarAction diff --git a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt index 057d89766..69d7f5682 100644 --- a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt @@ -1,15 +1,14 @@ package eu.kanade.presentation.components -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.sizeIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.ArrowLeft +import androidx.compose.material.icons.outlined.ArrowRight import androidx.compose.material.icons.outlined.RadioButtonChecked import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,13 +16,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import eu.kanade.tachiyomi.R -import me.saket.cascade.CascadeColumnScope -import me.saket.cascade.CascadeDropdownMenu import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu @Composable @@ -72,25 +71,28 @@ fun RadioMenuItem( } @Composable -fun OverflowMenu( - content: @Composable CascadeColumnScope.(() -> Unit) -> Unit, +fun NestedMenuItem( + text: @Composable () -> Unit, + children: @Composable ColumnScope.(() -> Unit) -> Unit, ) { - var moreExpanded by remember { mutableStateOf(false) } - val closeMenu = { moreExpanded = false } + var nestedExpanded by remember { mutableStateOf(false) } + val closeMenu = { nestedExpanded = false } + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - Box { - IconButton(onClick = { moreExpanded = !moreExpanded }) { + DropdownMenuItem( + text = text, + onClick = { nestedExpanded = true }, + trailingIcon = { Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.abc_action_menu_overflow_description), + imageVector = if (isLtr) Icons.Outlined.ArrowRight else Icons.Outlined.ArrowLeft, + contentDescription = null, ) - } - CascadeDropdownMenu( - expanded = moreExpanded, - onDismissRequest = closeMenu, - offset = DpOffset(8.dp, (-56).dp), - ) { - content(closeMenu) - } + }, + ) + DropdownMenu( + expanded = nestedExpanded, + onDismissRequest = closeMenu, + ) { + children(closeMenu) } } diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt index 698916cab..e13638ac2 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedDialog.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.pager.rememberPagerState @@ -43,13 +42,13 @@ fun TabbedDialog( onDismissRequest: () -> Unit, tabTitles: List, tabOverflowMenuContent: (@Composable ColumnScope.(() -> Unit) -> Unit)? = null, - content: @Composable (PaddingValues, Int) -> Unit, + content: @Composable (Int) -> Unit, ) { AdaptiveSheet( onDismissRequest = onDismissRequest, - ) { contentPadding -> + ) { val scope = rememberCoroutineScope() - val pagerState = rememberPagerState() + val pagerState = rememberPagerState { tabTitles.size } Column { Row { @@ -84,11 +83,10 @@ fun TabbedDialog( HorizontalPager( modifier = Modifier.animateContentSize(), - pageCount = tabTitles.size, state = pagerState, verticalAlignment = Alignment.Top, ) { page -> - content(contentPadding, page) + content(page) } } } diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index 08858e5c2..48e5123de 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt @@ -38,7 +38,7 @@ fun TabbedScreen( startIndex: Int? = null, mangaSearchQuery: String? = null, onChangeMangaSearchQuery: (String?) -> Unit = {}, - state: PagerState = rememberPagerState(), + state: PagerState = rememberPagerState { tabs.size }, scrollable: Boolean = false, animeSearchQuery: String? = null, onChangeAnimeSearchQuery: (String?) -> Unit = {}, @@ -105,7 +105,6 @@ fun TabbedScreen( } HorizontalPager( - pageCount = tabs.size, modifier = Modifier.fillMaxSize(), state = state, verticalAlignment = Alignment.Top, diff --git a/app/src/main/java/eu/kanade/presentation/entries/EntryToolbar.kt b/app/src/main/java/eu/kanade/presentation/entries/EntryToolbar.kt index 9e780190f..37a5ea383 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/EntryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/EntryToolbar.kt @@ -1,6 +1,5 @@ package eu.kanade.presentation.entries -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBack @@ -9,7 +8,6 @@ import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.SelectAll -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -19,8 +17,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource @@ -29,7 +29,6 @@ import androidx.compose.ui.unit.dp import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.EntryDownloadDropdownMenu -import eu.kanade.presentation.components.OverflowMenu import eu.kanade.tachiyomi.R import tachiyomi.presentation.core.theme.active @@ -94,84 +93,85 @@ fun EntryToolbar( ), ) } else { + var downloadExpanded by remember { mutableStateOf(false) } if (onClickDownload != null) { - val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) } - Box { - IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = stringResource(R.string.manga_download), - ) - } - val onDismissRequest = { onDownloadExpanded(false) } - EntryDownloadDropdownMenu( - expanded = downloadExpanded, - onDismissRequest = onDismissRequest, - onDownloadClicked = onClickDownload, - isManga = isManga, - ) - } + val onDismissRequest = { downloadExpanded = false } + EntryDownloadDropdownMenu( + expanded = downloadExpanded, + onDismissRequest = onDismissRequest, + onDownloadClicked = onClickDownload, + isManga = isManga, + ) } val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current - IconButton(onClick = onClickFilter) { - Icon(Icons.Outlined.FilterList, contentDescription = stringResource(R.string.action_filter), tint = filterTint) - } - - OverflowMenu { closeMenu -> - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_webview_refresh)) }, - onClick = { - onClickRefresh() - closeMenu() - }, - ) - if (onClickEditCategory != null) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_edit_categories)) }, - onClick = { - onClickEditCategory() - closeMenu() - }, + AppBarActions( + actions = buildList { + if (onClickDownload != null) { + add( + AppBar.Action( + title = stringResource(R.string.manga_download), + icon = Icons.Outlined.Download, + onClick = { downloadExpanded = !downloadExpanded }, + ), + ) + } + add( + AppBar.Action( + title = stringResource(R.string.action_filter), + icon = Icons.Outlined.FilterList, + iconTint = filterTint, + onClick = onClickFilter, + ), ) - } - if (onClickMigrate != null) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_migrate)) }, - onClick = { - onClickMigrate() - closeMenu() - }, + add( + AppBar.OverflowAction( + title = stringResource(R.string.action_webview_refresh), + onClick = onClickRefresh, + ), ) - } - if (changeAnimeSkipIntro != null) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_change_intro_length)) }, - onClick = { - changeAnimeSkipIntro() - closeMenu() - }, - ) - } - if (onClickShare != null) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_share)) }, - onClick = { - onClickShare() - closeMenu() - }, - ) - } - if (onClickSettings != null) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.settings)) }, - onClick = { - onClickSettings() - closeMenu() - }, - ) - } - } + if (onClickEditCategory != null) { + add( + AppBar.OverflowAction( + title = stringResource(R.string.action_edit_categories), + onClick = onClickEditCategory, + ), + ) + } + if (onClickMigrate != null) { + add( + AppBar.OverflowAction( + title = stringResource(R.string.action_migrate), + onClick = onClickMigrate, + ), + ) + } + if (changeAnimeSkipIntro != null) { + add( + AppBar.OverflowAction( + title = stringResource(R.string.action_change_intro_length), + onClick = changeAnimeSkipIntro, + ), + ) + } + if (onClickSettings != null) { + add( + AppBar.OverflowAction( + title = stringResource(R.string.settings), + onClick = onClickSettings, + ), + ) + } + if (onClickShare != null) { + add( + AppBar.OverflowAction( + title = stringResource(R.string.action_share), + onClick = onClickShare, + ), + ) + } + }, + ) } }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/EpisodeSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/EpisodeSettingsDialog.kt index 3715eccf2..d350d9159 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/EpisodeSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/EpisodeSettingsDialog.kt @@ -68,10 +68,9 @@ fun EpisodeSettingsDialog( }, ) }, - ) { contentPadding, page -> + ) { page -> Column( modifier = Modifier - .padding(contentPadding) .padding(vertical = TabbedDialogPaddings.Vertical) .verticalScroll(rememberScrollState()), ) { diff --git a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeCoverDialog.kt index cdd63a464..9f56d92e4 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/anime/components/AnimeCoverDialog.kt @@ -43,6 +43,8 @@ import coil.imageLoader import coil.request.CachePolicy import coil.request.ImageRequest import coil.size.Size +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.entries.EditCoverAction import eu.kanade.tachiyomi.R @@ -88,18 +90,24 @@ fun AnimeCoverDialog( } Spacer(modifier = Modifier.weight(1f)) ActionsPill { - IconButton(onClick = onShareClick) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = stringResource(R.string.action_share), - ) - } - IconButton(onClick = onSaveClick) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(R.string.action_save), - ) - } + AppBarActions( + actions = buildList { + add( + AppBar.Action( + title = stringResource(R.string.action_share), + icon = Icons.Outlined.Share, + onClick = onShareClick, + ), + ) + add( + AppBar.Action( + title = stringResource(R.string.action_save), + icon = Icons.Outlined.Save, + onClick = onSaveClick, + ), + ) + }, + ) if (onEditClick != null) { Box { var expanded by remember { mutableStateOf(false) } diff --git a/app/src/main/java/eu/kanade/presentation/entries/manga/ChapterSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/entries/manga/ChapterSettingsDialog.kt index cd2c28131..8143d8dd3 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/manga/ChapterSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/manga/ChapterSettingsDialog.kt @@ -68,10 +68,9 @@ fun ChapterSettingsDialog( }, ) }, - ) { contentPadding, page -> + ) { page -> Column( modifier = Modifier - .padding(contentPadding) .padding(vertical = TabbedDialogPaddings.Vertical) .verticalScroll(rememberScrollState()), ) { diff --git a/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaCoverDialog.kt index 14cad2f3e..31df9d512 100644 --- a/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/entries/manga/components/MangaCoverDialog.kt @@ -43,6 +43,8 @@ import coil.imageLoader import coil.request.CachePolicy import coil.request.ImageRequest import coil.size.Size +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.entries.EditCoverAction import eu.kanade.tachiyomi.R @@ -88,18 +90,24 @@ fun MangaCoverDialog( } Spacer(modifier = Modifier.weight(1f)) ActionsPill { - IconButton(onClick = onShareClick) { - Icon( - imageVector = Icons.Outlined.Share, - contentDescription = stringResource(R.string.action_share), - ) - } - IconButton(onClick = onSaveClick) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(R.string.action_save), - ) - } + AppBarActions( + actions = buildList { + add( + AppBar.Action( + title = stringResource(R.string.action_share), + icon = Icons.Outlined.Share, + onClick = onShareClick, + ), + ) + add( + AppBar.Action( + title = stringResource(R.string.action_save), + icon = Icons.Outlined.Save, + onClick = onSaveClick, + ), + ) + }, + ) if (onEditClick != null) { Box { var expanded by remember { mutableStateOf(false) } diff --git a/app/src/main/java/eu/kanade/presentation/library/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/LibraryToolbar.kt index 5ff127be6..1bf1cc147 100644 --- a/app/src/main/java/eu/kanade/presentation/library/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/LibraryToolbar.kt @@ -6,9 +6,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.SelectAll -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -22,7 +19,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions -import eu.kanade.presentation.components.OverflowMenu import eu.kanade.presentation.components.SearchToolbar import eu.kanade.tachiyomi.R import tachiyomi.presentation.core.components.Pill @@ -102,33 +98,28 @@ fun LibraryRegularToolbar( onChangeSearchQuery = onSearchQueryChange, actions = { val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current - IconButton(onClick = onClickFilter) { - Icon(Icons.Outlined.FilterList, contentDescription = stringResource(R.string.action_filter), tint = filterTint) - } - - OverflowMenu { closeMenu -> - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_update_library)) }, - onClick = { - onClickGlobalUpdate() - closeMenu() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_update_category)) }, - onClick = { - onClickRefresh() - closeMenu() - }, - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_open_random_manga)) }, - onClick = { - onClickOpenRandomEntry() - closeMenu() - }, - ) - } + AppBarActions( + listOf( + AppBar.Action( + title = stringResource(R.string.action_filter), + icon = Icons.Outlined.FilterList, + iconTint = filterTint, + onClick = onClickFilter, + ), + AppBar.OverflowAction( + title = stringResource(R.string.action_update_library), + onClick = onClickGlobalUpdate, + ), + AppBar.OverflowAction( + title = stringResource(R.string.action_update_category), + onClick = onClickRefresh, + ), + AppBar.OverflowAction( + title = stringResource(R.string.action_open_random_manga), + onClick = onClickOpenRandomEntry, + ), + ), + ) }, scrollBehavior = scrollBehavior, navigateUp = navigateUp, diff --git a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryContent.kt index 908448bf6..2bec27aab 100644 --- a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryContent.kt @@ -55,7 +55,7 @@ fun AnimeLibraryContent( ), ) { val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } - val pagerState = rememberPagerState(coercedCurrentPage) + val pagerState = rememberPagerState(coercedCurrentPage) { categories.size } val scope = rememberCoroutineScope() var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } @@ -99,7 +99,6 @@ fun AnimeLibraryContent( AnimeLibraryPager( state = pagerState, contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), - pageCount = categories.size, hasActiveFilters = hasActiveFilters, selectedAnime = selection, searchQuery = searchQuery, diff --git a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryPager.kt index 79a5e219f..fde2e8ae7 100644 --- a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibraryPager.kt @@ -22,7 +22,6 @@ import tachiyomi.presentation.core.components.HorizontalPager fun AnimeLibraryPager( state: PagerState, contentPadding: PaddingValues, - pageCount: Int, hasActiveFilters: Boolean, selectedAnime: List, searchQuery: String?, @@ -35,7 +34,6 @@ fun AnimeLibraryPager( onClickContinueWatching: ((LibraryAnime) -> Unit)?, ) { HorizontalPager( - pageCount = pageCount, modifier = Modifier.fillMaxSize(), state = state, verticalAlignment = Alignment.Top, diff --git a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt index 0b4075e51..d3187c724 100644 --- a/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/library/anime/AnimeLibrarySettingsDialog.kt @@ -54,10 +54,9 @@ fun AnimeLibrarySettingsDialog( stringResource(R.string.action_sort), stringResource(R.string.action_display), ), - ) { contentPadding, page -> + ) { page -> Column( modifier = Modifier - .padding(contentPadding) .padding(vertical = TabbedDialogPaddings.Vertical) .verticalScroll(rememberScrollState()), ) { @@ -220,23 +219,25 @@ private fun ColumnScope.DisplayPage( } } - val columns by columnPreference.changes().collectAsState(initial = 0) - Column { + val columns by columnPreference.collectAsState() + Column(modifier = Modifier.weight(0.5f)) { Text( stringResource(id = R.string.pref_library_columns), style = MaterialTheme.typography.bodyMedium, ) - if (columns > 0) { - Text(stringResource(id = R.string.pref_library_columns_per_row, columns)) - } else { - Text(stringResource(id = R.string.label_default)) - } + Text( + if (columns > 0) { + stringResource(id = R.string.pref_library_columns_per_row, columns) + } else { + stringResource(id = R.string.label_default) + }, + ) } Slider( value = columns.toFloat(), onValueChange = { columnPreference.set(it.toInt()) }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1.5f), valueRange = 0f..10f, steps = 10, ) diff --git a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryContent.kt index ae0ecc931..7e1af1e42 100644 --- a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryContent.kt @@ -55,7 +55,7 @@ fun MangaLibraryContent( ), ) { val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } - val pagerState = rememberPagerState(coercedCurrentPage) + val pagerState = rememberPagerState(coercedCurrentPage) { categories.size } val scope = rememberCoroutineScope() var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } @@ -99,7 +99,6 @@ fun MangaLibraryContent( MangaLibraryPager( state = pagerState, contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), - pageCount = categories.size, hasActiveFilters = hasActiveFilters, selectedManga = selection, searchQuery = searchQuery, diff --git a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryPager.kt index 030fbee31..8d87b3722 100644 --- a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibraryPager.kt @@ -31,7 +31,6 @@ import tachiyomi.presentation.core.util.plus fun MangaLibraryPager( state: PagerState, contentPadding: PaddingValues, - pageCount: Int, hasActiveFilters: Boolean, selectedManga: List, searchQuery: String?, @@ -44,7 +43,6 @@ fun MangaLibraryPager( onClickContinueReading: ((LibraryManga) -> Unit)?, ) { HorizontalPager( - pageCount = pageCount, modifier = Modifier.fillMaxSize(), state = state, verticalAlignment = Alignment.Top, diff --git a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt index 33f8d1394..cd4fcd057 100644 --- a/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/library/manga/MangaLibrarySettingsDialog.kt @@ -54,10 +54,9 @@ fun MangaLibrarySettingsDialog( stringResource(R.string.action_sort), stringResource(R.string.action_display), ), - ) { contentPadding, page -> + ) { page -> Column( modifier = Modifier - .padding(contentPadding) .padding(vertical = TabbedDialogPaddings.Vertical) .verticalScroll(rememberScrollState()), ) { @@ -219,23 +218,25 @@ private fun ColumnScope.DisplayPage( } } - val columns by columnPreference.changes().collectAsState(initial = 0) - Column { + val columns by columnPreference.collectAsState() + Column(modifier = Modifier.weight(0.5f)) { Text( stringResource(id = R.string.pref_library_columns), style = MaterialTheme.typography.bodyMedium, ) - if (columns > 0) { - Text(stringResource(id = R.string.pref_library_columns_per_row, columns)) - } else { - Text(stringResource(id = R.string.label_default)) - } + Text( + if (columns > 0) { + stringResource(id = R.string.pref_library_columns_per_row, columns) + } else { + stringResource(id = R.string.label_default) + }, + ) } Slider( value = columns.toFloat(), onValueChange = { columnPreference.set(it.toInt()) }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1.5f), valueRange = 0f..10f, steps = 10, ) diff --git a/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogSelector.kt b/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogSelector.kt index 3855b395f..5137b934a 100644 --- a/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogSelector.kt +++ b/app/src/main/java/eu/kanade/presentation/track/TrackInfoDialogSelector.kt @@ -19,6 +19,7 @@ import androidx.compose.material.minimumInteractiveComponentSize import androidx.compose.material3.DatePicker import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState @@ -140,13 +141,14 @@ fun TrackScoreSelector( fun TrackDateSelector( title: String, initialSelectedDateMillis: Long, - dateValidator: (Long) -> Boolean, + selectableDates: SelectableDates, onConfirm: (Long) -> Unit, onRemove: (() -> Unit)?, onDismissRequest: () -> Unit, ) { val pickerState = rememberDatePickerState( initialSelectedDateMillis = initialSelectedDateMillis, + selectableDates = selectableDates, ) AlertDialogContent( modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars), @@ -155,7 +157,6 @@ fun TrackDateSelector( Column { DatePicker( state = pickerState, - dateValidator = dateValidator, title = null, headline = null, showModeToggle = false, diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index ddf100053..ca1afc986 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,8 +1,12 @@ package eu.kanade.presentation.util +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -10,7 +14,7 @@ import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.ScreenTransition +import cafe.adriel.voyager.transitions.ScreenTransitionContent import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -65,3 +69,22 @@ fun DefaultNavigatorScreenTransition(navigator: Navigator) { }, ) } + +@Composable +fun ScreenTransition( + navigator: Navigator, + transition: AnimatedContentTransitionScope.() -> ContentTransform, + modifier: Modifier = Modifier, + content: ScreenTransitionContent = { it.Content() }, +) { + AnimatedContent( + targetState = navigator.lastItem, + transitionSpec = transition, + modifier = modifier, + label = "", + ) { screen -> + navigator.saveableState("transition", screen) { + content(screen) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt index 661ae3fc8..5b607b8a2 100644 --- a/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt +++ b/app/src/main/java/eu/kanade/presentation/webview/WebViewScreenContent.kt @@ -114,7 +114,7 @@ fun WebViewScreenContent( ) { contentPadding -> val webClient = remember { object : AccompanistWebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) url?.let { onUrlChange(it) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 5d3294649..9b135b451 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -28,7 +28,6 @@ import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.entries.TriStateFilter import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences.Companion.ENTRY_NON_COMPLETED -import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -150,7 +149,6 @@ object Migrations { // Force MAL log out due to login flow change // v52: switched from scraping to WebView // v53: switched from WebView to OAuth - val trackManager = Injekt.get() if (trackManager.myAnimeList.isLogged) { trackManager.myAnimeList.logout() context.toast(R.string.myanimelist_relogin) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt index 4d31df92a..01eef646c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/anime/AnimeDownloader.kt @@ -12,7 +12,6 @@ import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.LogCallback import com.arthenica.ffmpegkit.SessionState import com.hippo.unifile.UniFile -import com.jakewharton.rxrelay.PublishRelay import eu.kanade.domain.items.episode.model.toSEpisode import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.UnmeteredSource @@ -26,16 +25,26 @@ import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.toFFmpegString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import logcat.LogPriority import okhttp3.HttpUrl.Companion.toHttpUrl import rx.Observable -import rx.Subscription import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers import rx.subjects.PublishSubject import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchNow @@ -52,6 +61,7 @@ import uy.kohesive.injekt.injectLazy import java.io.File import java.util.Locale import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException /** * This class is the one in charge of downloading episodes. @@ -87,15 +97,8 @@ class AnimeDownloader( */ private val notifier by lazy { AnimeDownloadNotifier(context) } - /** - * AnimeDownloader subscription. - */ - private var subscription: Subscription? = null - - /** - * Relay to send a list of downloads to the downloader. - */ - private val downloadsRelay = PublishRelay.create>() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var downloaderJob: Job? = null /** * Preference for user's choice of external downloader @@ -106,7 +109,7 @@ class AnimeDownloader( * Whether the downloader is running. */ val isRunning: Boolean - get() = subscription != null + get() = downloaderJob?.isActive ?: false /** * Whether the downloader is paused @@ -134,18 +137,17 @@ class AnimeDownloader( * @return true if the downloader is started, false otherwise. */ fun start(): Boolean { - if (subscription != null || queueState.value.isEmpty()) { + if (isRunning || queueState.value.isEmpty()) { return false } - initializeSubscription() - val pending = queueState.value.filter { it.status != AnimeDownload.State.DOWNLOADED } pending.forEach { if (it.status != AnimeDownload.State.QUEUE) it.status = AnimeDownload.State.QUEUE } isPaused = false - downloadsRelay.call(pending) + launchDownloaderJob() + return pending.isNotEmpty() } @@ -153,7 +155,7 @@ class AnimeDownloader( * Stops the downloader. */ fun stop(reason: String? = null) { - destroySubscription() + cancelDownloaderJob() queueState.value .filter { it.status == AnimeDownload.State.DOWNLOADING } .forEach { it.status = AnimeDownload.State.ERROR } @@ -181,7 +183,7 @@ class AnimeDownloader( * Pauses the downloader */ fun pause() { - destroySubscription() + cancelDownloaderJob() queueState.value .filter { it.status == AnimeDownload.State.DOWNLOADING } .forEach { it.status = AnimeDownload.State.QUEUE } @@ -192,7 +194,7 @@ class AnimeDownloader( * Removes everything from the queue. */ fun clearQueue() { - destroySubscription() + cancelDownloaderJob() _clearQueue() notifier.dismissProgress() @@ -201,52 +203,72 @@ class AnimeDownloader( /** * Prepares the subscriptions to start downloading. */ - private fun initializeSubscription() { - // Unsubscribe the previous subscription if it exists - destroySubscription() + private fun launchDownloaderJob() { + if (isRunning) return - subscription = downloadsRelay.flatMapIterable { it } - // Concurrently download from 3 different sources - .groupBy { it.source } - .flatMap( - { bySource -> - bySource.flatMap( - { download -> - downloadEpisode(download) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - }, - if (sourceManager.get(bySource.key.id) is UnmeteredSource) { - downloadPreferences.numberOfDownloads().get() - } else { - 1 - }, - ) - }, - 5, - ) - .subscribe( - { - // Remove successful download from queue - if (it.status == AnimeDownload.State.DOWNLOADED) { - removeFromQueue(it) + downloaderJob = scope.launch { + val activeDownloadsFlow = queueState.transformLatest { queue -> + while (true) { + val activeDownloads = queue.asSequence() + .filter { it.status.value <= AnimeDownload.State.DOWNLOADING.value } // Ignore completed downloads, leave them in the queue + .groupBy { it.source } + .toList().take(5) // Concurrently download from 5 different sources + .map { (_, downloads) -> downloads.first() } + emit(activeDownloads) + + if (activeDownloads.isEmpty()) break + // Suspend until a download enters the ERROR state + val activeDownloadsErroredFlow = + combine(activeDownloads.map(AnimeDownload::statusFlow)) { states -> + states.contains(AnimeDownload.State.ERROR) + }.filter { it } + activeDownloadsErroredFlow.first() + } + }.distinctUntilChanged() + + // Use supervisorScope to cancel child jobs when the downloader job is cancelled + supervisorScope { + val downloadJobs = mutableMapOf() + + activeDownloadsFlow.collectLatest { activeDownloads -> + val downloadJobsToStop = downloadJobs.filter { it.key !in activeDownloads } + downloadJobsToStop.forEach { (download, job) -> + job.cancel() + downloadJobs.remove(download) } - if (areAllAnimeDownloadsFinished()) { - stop() + + val downloadsToStart = activeDownloads.filter { it !in downloadJobs } + downloadsToStart.forEach { download -> + downloadJobs[download] = launchDownloadJob(download) } - }, - { error -> - logcat(LogPriority.ERROR, error) - notifier.onError(error.message) - stop() - }, - ) + } + } + } + } + + private fun CoroutineScope.launchDownloadJob(download: AnimeDownload) = launchIO { + try { + downloadEpisode(download) + + // Remove successful download from queue + if (download.status == AnimeDownload.State.DOWNLOADED) { + removeFromQueue(download) + } + if (areAllAnimeDownloadsFinished()) { + stop() + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) + notifier.onError(e.message) + stop() + } } /** * Destroys the downloader subscriptions. */ - private fun destroySubscription() { + private fun cancelDownloaderJob() { isFFmpegRunning = false FFmpegKitConfig.getSessions().filter { it.isFFmpeg && (it.state == SessionState.CREATED || it.state == SessionState.RUNNING) @@ -254,8 +276,8 @@ class AnimeDownloader( it.cancel() } - subscription?.unsubscribe() - subscription = null + downloaderJob?.cancel() + downloaderJob = null } /** @@ -272,17 +294,13 @@ class AnimeDownloader( val source = sourceManager.get(anime.source) as? AnimeHttpSource ?: return@launchIO val wasEmpty = queueState.value.isEmpty() - // Called in background thread, the operation can be slow with SAF. - val episodesWithoutDir = async { - episodes - // Filter out those already downloaded. - .filter { provider.findEpisodeDir(it.name, it.scanlator, anime.title, source) == null } - // Add episodes to queue from the start. - .sortedByDescending { it.sourceOrder } - } + val episodesWithoutDir = episodes + // Filter out those already downloaded. + .filter { provider.findEpisodeDir(it.name, it.scanlator, anime.title, source) == null } + // Add chapters to queue from the start. + .sortedByDescending { it.sourceOrder } - // Runs in main thread (synchronization needed). - val episodesToQueue = episodesWithoutDir.await() + val episodesToQueue = episodesWithoutDir // Filter out those already enqueued. .filter { episode -> queueState.value.none { it.episode.id == episode.id } } // Create a download for each one. @@ -291,11 +309,6 @@ class AnimeDownloader( if (episodesToQueue.isNotEmpty()) { addAllToQueue(episodesToQueue) - if (isRunning) { - // Send the list of downloads to the downloader. - downloadsRelay.call(episodesToQueue) - } - // Start downloader if needed if (autoStart && wasEmpty) { val queuedDownloads = queueState.value.count { it: AnimeDownload -> it.source !is UnmeteredSource } @@ -515,7 +528,12 @@ class AnimeDownloader( } var duration = 0L + var nextLineIsDuration = false val logCallback = LogCallback { log -> + if (nextLineIsDuration) { + parseDuration(log.message)?.let { duration = it } + nextLineIsDuration = false + } if (log.level <= Level.AV_LOG_WARNING) log.message?.let { logcat { it } } if (duration != 0L && log.message.startsWith("frame=")) { val outTime = log.message @@ -806,7 +824,7 @@ class AnimeDownloader( } } - private inline fun removeFromQueueByPredicate(predicate: (AnimeDownload) -> Boolean) { + private inline fun removeFromQueueIf(predicate: (AnimeDownload) -> Boolean) { _queueState.update { queue -> val downloads = queue.filter { predicate(it) } store.removeAll(downloads) @@ -821,11 +839,11 @@ class AnimeDownloader( fun removeFromQueue(episodes: List) { val episodeIds = episodes.map { it.id } - removeFromQueueByPredicate { it.episode.id in episodeIds } + removeFromQueueIf { it.episode.id in episodeIds } } fun removeFromQueue(anime: Anime) { - removeFromQueueByPredicate { it.anime.id == anime.id } + removeFromQueueIf { it.anime.id == anime.id } } private fun _clearQueue() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloader.kt index 626f63129..63265c224 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/MangaDownloader.kt @@ -4,7 +4,6 @@ import android.content.Context import aniyomi.util.DataSaver import aniyomi.util.DataSaver.Companion.getImage import com.hippo.unifile.UniFile -import com.jakewharton.rxrelay.PublishRelay import eu.kanade.domain.entries.manga.model.getComicInfo import eu.kanade.domain.items.chapter.model.toSChapter import eu.kanade.domain.source.service.SourcePreferences @@ -20,26 +19,31 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.saveTo import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import logcat.LogPriority import nl.adaptivity.xmlutil.serialization.XML import okhttp3.Response -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.util.lang.awaitSingle @@ -99,21 +103,14 @@ class MangaDownloader( */ private val notifier by lazy { MangaDownloadNotifier(context) } - /** - * Downloader subscription. - */ - private var subscription: Subscription? = null - - /** - * Relay to send a list of downloads to the downloader. - */ - private val downloadsRelay = PublishRelay.create>() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var downloaderJob: Job? = null /** * Whether the downloader is running. */ val isRunning: Boolean - get() = subscription != null + get() = downloaderJob?.isActive ?: false /** * Whether the downloader is paused @@ -135,18 +132,17 @@ class MangaDownloader( * @return true if the downloader is started, false otherwise. */ fun start(): Boolean { - if (subscription != null || queueState.value.isEmpty()) { + if (isRunning || queueState.value.isEmpty()) { return false } - initializeSubscription() - val pending = queueState.value.filter { it.status != MangaDownload.State.DOWNLOADED } pending.forEach { if (it.status != MangaDownload.State.QUEUE) it.status = MangaDownload.State.QUEUE } isPaused = false - downloadsRelay.call(pending) + launchDownloaderJob() + return pending.isNotEmpty() } @@ -154,7 +150,7 @@ class MangaDownloader( * Stops the downloader. */ fun stop(reason: String? = null) { - destroySubscription() + cancelDownloaderJob() queueState.value .filter { it.status == MangaDownload.State.DOWNLOADING } .forEach { it.status = MangaDownload.State.ERROR } @@ -182,7 +178,7 @@ class MangaDownloader( * Pauses the downloader */ fun pause() { - destroySubscription() + cancelDownloaderJob() queueState.value .filter { it.status == MangaDownload.State.DOWNLOADING } .forEach { it.status = MangaDownload.State.QUEUE } @@ -193,7 +189,7 @@ class MangaDownloader( * Removes everything from the queue. */ fun clearQueue() { - destroySubscription() + cancelDownloaderJob() _clearQueue() notifier.dismissProgress() @@ -202,49 +198,74 @@ class MangaDownloader( /** * Prepares the subscriptions to start downloading. */ - private fun initializeSubscription() { - if (subscription != null) return + private fun launchDownloaderJob() { + if (isRunning) return - subscription = downloadsRelay.concatMapIterable { it } - // Concurrently download from 5 different sources - .groupBy { it.source } - .flatMap( - { bySource -> - bySource.concatMap { download -> - Observable.fromCallable { - runBlocking { downloadChapter(download) } - download - }.subscribeOn(Schedulers.io()) + downloaderJob = scope.launch { + val activeDownloadsFlow = queueState.transformLatest { queue -> + while (true) { + val activeDownloads = queue.asSequence() + .filter { it.status.value <= MangaDownload.State.DOWNLOADING.value } // Ignore completed downloads, leave them in the queue + .groupBy { it.source } + .toList().take(5) // Concurrently download from 5 different sources + .map { (_, downloads) -> downloads.first() } + emit(activeDownloads) + + if (activeDownloads.isEmpty()) break + // Suspend until a download enters the ERROR state + val activeDownloadsErroredFlow = + combine(activeDownloads.map(MangaDownload::statusFlow)) { states -> + states.contains(MangaDownload.State.ERROR) + }.filter { it } + activeDownloadsErroredFlow.first() + } + }.distinctUntilChanged() + + // Use supervisorScope to cancel child jobs when the downloader job is cancelled + supervisorScope { + val downloadJobs = mutableMapOf() + + activeDownloadsFlow.collectLatest { activeDownloads -> + val downloadJobsToStop = downloadJobs.filter { it.key !in activeDownloads } + downloadJobsToStop.forEach { (download, job) -> + job.cancel() + downloadJobs.remove(download) } - }, - 5, - ) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { - // Remove successful download from queue - if (it.status == MangaDownload.State.DOWNLOADED) { - removeFromQueue(it) + + val downloadsToStart = activeDownloads.filter { it !in downloadJobs } + downloadsToStart.forEach { download -> + downloadJobs[download] = launchDownloadJob(download) } - if (areAllDownloadsFinished()) { - stop() - } - }, - { error -> - logcat(LogPriority.ERROR, error) - notifier.onError(error.message) - stop() - }, - ) + } + } + } + } + + private fun CoroutineScope.launchDownloadJob(download: MangaDownload) = launchIO { + try { + downloadChapter(download) + + // Remove successful download from queue + if (download.status == MangaDownload.State.DOWNLOADED) { + removeFromQueue(download) + } + if (areAllDownloadsFinished()) { + stop() + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) + notifier.onError(e.message) + stop() + } } /** * Destroys the downloader subscriptions. */ - private fun destroySubscription() { - subscription?.unsubscribe() - subscription = null + private fun cancelDownloaderJob() { + downloaderJob?.cancel() + downloaderJob = null } /** @@ -261,17 +282,13 @@ class MangaDownloader( val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO val wasEmpty = queueState.value.isEmpty() - // Called in background thread, the operation can be slow with SAF. - val chaptersWithoutDir = async { - chapters - // Filter out those already downloaded. - .filter { provider.findChapterDir(it.name, it.scanlator, manga.title, source) == null } - // Add chapters to queue from the start. - .sortedByDescending { it.sourceOrder } - } + val chaptersWithoutDir = chapters + // Filter out those already downloaded. + .filter { provider.findChapterDir(it.name, it.scanlator, manga.title, source) == null } + // Add chapters to queue from the start. + .sortedByDescending { it.sourceOrder } - // Runs in main thread (synchronization needed). - val chaptersToQueue = chaptersWithoutDir.await() + val chaptersToQueue = chaptersWithoutDir // Filter out those already enqueued. .filter { chapter -> queueState.value.none { it.chapter.id == chapter.id } } // Create a download for each one. @@ -280,11 +297,6 @@ class MangaDownloader( if (chaptersToQueue.isNotEmpty()) { addAllToQueue(chaptersToQueue) - if (isRunning) { - // Send the list of downloads to the downloader. - downloadsRelay.call(chaptersToQueue) - } - // Start downloader if needed if (autoStart && wasEmpty) { val queuedDownloads = queueState.value.count { it: MangaDownload -> it.source !is UnmeteredSource } @@ -665,7 +677,7 @@ class MangaDownloader( } } - private inline fun removeFromQueueByPredicate(predicate: (MangaDownload) -> Boolean) { + private inline fun removeFromQueueIf(predicate: (MangaDownload) -> Boolean) { _queueState.update { queue -> val downloads = queue.filter { predicate(it) } store.removeAll(downloads) @@ -680,11 +692,11 @@ class MangaDownloader( fun removeFromQueue(chapters: List) { val chapterIds = chapters.map { it.id } - removeFromQueueByPredicate { it.chapter.id in chapterIds } + removeFromQueueIf { it.chapter.id in chapterIds } } fun removeFromQueue(manga: Manga) { - removeFromQueueByPredicate { it.manga.id == manga.id } + removeFromQueueIf { it.manga.id == manga.id } } private fun _clearQueue() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/model/MangaDownload.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/model/MangaDownload.kt index 3af7dc0eb..d7c2e46f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/model/MangaDownload.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/manga/model/MangaDownload.kt @@ -22,9 +22,10 @@ data class MangaDownload( val source: HttpSource, val manga: Manga, val chapter: Chapter, - var pages: List? = null, ) { + var pages: List? = null + val totalProgress: Int get() = pages?.sumOf(Page::progress) ?: 0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/source/browse/SourceFilterAnimeDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/source/browse/SourceFilterAnimeDialog.kt index 6f328a41c..973df91f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/source/browse/SourceFilterAnimeDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/anime/source/browse/SourceFilterAnimeDialog.kt @@ -42,10 +42,8 @@ fun SourceFilterAnimeDialog( AdaptiveSheet( onDismissRequest = onDismissRequest, - ) { contentPadding -> - LazyColumn( - contentPadding = contentPadding, - ) { + ) { + LazyColumn { stickyHeader { Row( modifier = Modifier diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/source/browse/SourceFilterMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/source/browse/SourceFilterMangaDialog.kt index 567e899ac..5a388701b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/source/browse/SourceFilterMangaDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/manga/source/browse/SourceFilterMangaDialog.kt @@ -42,10 +42,8 @@ fun SourceFilterMangaDialog( AdaptiveSheet( onDismissRequest = onDismissRequest, - ) { contentPadding -> - LazyColumn( - contentPadding = contentPadding, - ) { + ) { + LazyColumn { stickyHeader { Row( modifier = Modifier diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/track/AnimeTrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/track/AnimeTrackInfoDialog.kt index 57f982287..c7a3a1c38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/track/AnimeTrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/anime/track/AnimeTrackInfoDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -425,6 +426,62 @@ private data class TrackDateSelectorScreen( private val start: Boolean, ) : Screen() { + private val selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val dateToCheck = Instant.ofEpochMilli(utcTimeMillis) + .atZone(ZoneOffset.systemDefault()) + .toLocalDate() + + if (dateToCheck > LocalDate.now()) { + // Disallow future dates + return false + } + + return if (start && track.finishDate > 0) { + // Disallow start date to be set later than finish date + val dateFinished = Instant.ofEpochMilli(track.finishDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + dateToCheck <= dateFinished + } else if (!start && track.startDate > 0) { + // Disallow end date to be set earlier than start date + val dateStarted = Instant.ofEpochMilli(track.startDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + dateToCheck >= dateStarted + } else { + // Nothing set before + true + } + } + + override fun isSelectableYear(year: Int): Boolean { + if (year > LocalDate.now().year) { + // Disallow future dates + return false + } + + return if (start && track.finishDate > 0) { + // Disallow start date to be set later than finish date + val dateFinished = Instant.ofEpochMilli(track.finishDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .year + year <= dateFinished + } else if (!start && track.startDate > 0) { + // Disallow end date to be set earlier than start date + val dateStarted = Instant.ofEpochMilli(track.startDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .year + year >= dateStarted + } else { + // Nothing set before + true + } + } + } + @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow @@ -448,33 +505,7 @@ private data class TrackDateSelectorScreen( stringResource(R.string.track_finished_reading_date) }, initialSelectedDateMillis = sm.initialSelection, - dateValidator = { utcMillis -> - val dateToCheck = Instant.ofEpochMilli(utcMillis) - .atZone(ZoneOffset.systemDefault()) - .toLocalDate() - - if (dateToCheck > LocalDate.now()) { - // Disallow future dates - return@TrackDateSelector false - } - - if (start && track.finishDate > 0) { - // Disallow start date to be set later than finish date - val dateFinished = Instant.ofEpochMilli(track.finishDate) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - dateToCheck <= dateFinished - } else if (!start && track.startDate > 0) { - // Disallow end date to be set earlier than start date - val dateStarted = Instant.ofEpochMilli(track.startDate) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - dateToCheck >= dateStarted - } else { - // Nothing set before - true - } - }, + selectableDates = selectableDates, onConfirm = { sm.setDate(it); navigator.pop() }, onRemove = { sm.confirmRemoveDate(navigator) }.takeIf { canRemove }, onDismissRequest = navigator::pop, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/track/MangaTrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/track/MangaTrackInfoDialog.kt index a88a27467..3ae75cfec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/track/MangaTrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/entries/manga/track/MangaTrackInfoDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -424,6 +425,62 @@ private data class TrackDateSelectorScreen( private val start: Boolean, ) : Screen() { + private val selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val dateToCheck = Instant.ofEpochMilli(utcTimeMillis) + .atZone(ZoneOffset.systemDefault()) + .toLocalDate() + + if (dateToCheck > LocalDate.now()) { + // Disallow future dates + return false + } + + return if (start && track.finishDate > 0) { + // Disallow start date to be set later than finish date + val dateFinished = Instant.ofEpochMilli(track.finishDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + dateToCheck <= dateFinished + } else if (!start && track.startDate > 0) { + // Disallow end date to be set earlier than start date + val dateStarted = Instant.ofEpochMilli(track.startDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + dateToCheck >= dateStarted + } else { + // Nothing set before + true + } + } + + override fun isSelectableYear(year: Int): Boolean { + if (year > LocalDate.now().year) { + // Disallow future dates + return false + } + + return if (start && track.finishDate > 0) { + // Disallow start date to be set later than finish date + val dateFinished = Instant.ofEpochMilli(track.finishDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .year + year <= dateFinished + } else if (!start && track.startDate > 0) { + // Disallow end date to be set earlier than start date + val dateStarted = Instant.ofEpochMilli(track.startDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .year + year >= dateStarted + } else { + // Nothing set before + true + } + } + } + @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow @@ -447,33 +504,7 @@ private data class TrackDateSelectorScreen( stringResource(R.string.track_finished_reading_date) }, initialSelectedDateMillis = sm.initialSelection, - dateValidator = { utcMillis -> - val dateToCheck = Instant.ofEpochMilli(utcMillis) - .atZone(ZoneOffset.systemDefault()) - .toLocalDate() - - if (dateToCheck > LocalDate.now()) { - // Disallow future dates - return@TrackDateSelector false - } - - if (start && track.finishDate > 0) { - // Disallow start date to be set later than finish date - val dateFinished = Instant.ofEpochMilli(track.finishDate) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - dateToCheck <= dateFinished - } else if (!start && track.startDate > 0) { - // Disallow end date to be set earlier than start date - val dateStarted = Instant.ofEpochMilli(track.startDate) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - dateToCheck >= dateStarted - } else { - // Nothing set before - true - } - }, + selectableDates = selectableDates, onConfirm = { sm.setDate(it); navigator.pop() }, onRemove = { sm.confirmRemoveDate(navigator) }.takeIf { canRemove }, onDismissRequest = navigator::pop, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index 8b3651550..7205eb4e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets @@ -155,7 +155,7 @@ object HomeScreen : Screen() { AnimatedContent( targetState = tabNavigator.current, transitionSpec = { - materialFadeThroughIn(initialScale = 1f, durationMillis = TabFadeDuration) with + materialFadeThroughIn(initialScale = 1f, durationMillis = TabFadeDuration) togetherWith materialFadeThroughOut(durationMillis = TabFadeDuration) }, content = { diff --git a/app/src/main/res/layout/reader_activity.xml b/app/src/main/res/layout/reader_activity.xml index 649107ca8..650fbcd6c 100644 --- a/app/src/main/res/layout/reader_activity.xml +++ b/app/src/main/res/layout/reader_activity.xml @@ -1,4 +1,4 @@ - - + diff --git a/core/src/main/java/eu/kanade/tachiyomi/network/NetworkPreferences.kt b/core/src/main/java/eu/kanade/tachiyomi/network/NetworkPreferences.kt index 1fdb1d81a..4c9a4000a 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/network/NetworkPreferences.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/network/NetworkPreferences.kt @@ -17,6 +17,6 @@ class NetworkPreferences( } fun defaultUserAgent(): Preference { - return preferenceStore.getString("default_user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:110.0) Gecko/20100101 Firefox/110.0") + return preferenceStore.getString("default_user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:110.0) Gecko/20100101 Firefox/111.0") } } diff --git a/core/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/core/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index beef906d7..5db238c4d 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -12,7 +12,7 @@ import tachiyomi.core.util.system.logcat object WebViewUtil { const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" - const val MINIMUM_WEBVIEW_VERSION = 108 + const val MINIMUM_WEBVIEW_VERSION = 109 fun supportsWebView(context: Context): Boolean { try { diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index 7e9f3cb6e..55c0852ee 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -9,10 +9,9 @@ annotation = "androidx.annotation:annotation:1.7.0-alpha02" appcompat = "androidx.appcompat:appcompat:1.6.1" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0" -corektx = "androidx.core:core-ktx:1.11.0-alpha04" +corektx = "androidx.core:core-ktx:1.11.0-beta01" splashscreen = "androidx.core:core-splashscreen:1.0.0-alpha02" -recyclerview = "androidx.recyclerview:recyclerview:1.3.0" +recyclerview = "androidx.recyclerview:recyclerview:1.3.1-rc01" viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01" glance = "androidx.glance:glance-appwidget:1.0.0-alpha03" profileinstaller = "androidx.profileinstaller:profileinstaller:1.3.1" @@ -26,7 +25,7 @@ work-runtime = "androidx.work:work-runtime-ktx:2.8.1" guava = "com.google.guava:guava:31.1-android" paging-runtime = "androidx.paging:paging-runtime:3.1.1" -paging-compose = "androidx.paging:paging-compose:1.0.0-alpha19" +paging-compose = "androidx.paging:paging-compose:1.0.0-alpha20" benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.1.1" test-ext = "androidx.test.ext:junit-ktx:1.1.5" diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 3264abb3e..6ede0b85f 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -1,10 +1,10 @@ [versions] compiler = "1.4.7" -compose-bom = "2023.03.00" -accompanist = "0.30.1" +compose-bom = "2023.04.00-alpha04" +accompanist = "0.31.2-alpha" [libraries] -activity = "androidx.activity:activity-compose:1.7.1" +activity = "androidx.activity:activity-compose:1.7.2" bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" } foundation = { module = "androidx.compose.foundation:foundation" } animation = { module = "androidx.compose.animation:animation" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2a0d759f..2163c1f65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coil_version = "2.3.0" shizuku_version = "12.2.0" sqlite = "2.3.1" sqldelight = "1.5.5" -leakcanary = "2.10" +leakcanary = "2.11" voyager = "1.0.0-rc06" richtext = "0.16.0" @@ -35,7 +35,7 @@ junrar = "com.github.junrar:junrar:7.5.4" sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" } -sqlite-android = "com.github.requery:sqlite-android:3.41.1" +sqlite-android = "com.github.requery:sqlite-android:3.42.0" preferencektx = "androidx.preference:preference-ktx:1.2.0" @@ -55,12 +55,10 @@ richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", material = "com.google.android.material:material:1.9.0" flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533" -flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c8013533" photoview = "com.github.chrisbanes:PhotoView:2.3.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" insetter = "dev.chrisbanes.insetter:insetter:0.6.1" -compose-cascade = "me.saket.cascade:cascade-compose:2.0.0-rc02" -compose-materialmotion = "io.github.fornewid:material-motion-compose-core:0.12.3" +compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.2" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0" logcat = "com.squareup.logcat:logcat:0.1" @@ -84,6 +82,7 @@ sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version. junit = "org.junit.jupiter:junit-jupiter:5.9.3" kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2" +mockk = "io.mockk:mockk:1.13.5" voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } @@ -91,8 +90,6 @@ voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", vers kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0" -mockk = "io.mockk:mockk:1.13.5" - aniyomi-mpv = "com.github.aniyomiorg:aniyomi-mpv-lib:1.10.n" ffmpeg-kit = "com.github.jmir1:ffmpeg-kit:1.10" arthenica-smartexceptions = "com.arthenica:smart-exception-java:0.1.1" diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt index c62b315c8..ec1c48b5a 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pager.kt @@ -4,9 +4,9 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerScope import androidx.compose.foundation.pager.PagerSnapDistance import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -21,9 +21,8 @@ import androidx.compose.ui.unit.dp */ @Composable fun HorizontalPager( - pageCount: Int, + state: PagerState, modifier: Modifier = Modifier, - state: PagerState = rememberPagerState(), contentPadding: PaddingValues = PaddingValues(0.dp), pageSize: PageSize = PageSize.Fill, beyondBoundsPageCount: Int = 0, @@ -35,12 +34,11 @@ fun HorizontalPager( pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( Orientation.Horizontal, ), - pageContent: @Composable (page: Int) -> Unit, + pageContent: @Composable PagerScope.(page: Int) -> Unit, ) { androidx.compose.foundation.pager.HorizontalPager( - pageCount = pageCount, - modifier = modifier, state = state, + modifier = modifier, contentPadding = contentPadding, pageSize = pageSize, beyondBoundsPageCount = beyondBoundsPageCount, diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt index b7d769f35..32c3c5ba1 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/VerticalFastScroller.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMaxBy +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest @@ -60,6 +61,7 @@ import kotlin.math.roundToInt * * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. */ +@OptIn(FlowPreview::class) @Composable fun VerticalFastScroller( listState: LazyListState, @@ -217,6 +219,7 @@ private fun rememberColumnWidthSums( } } +@OptIn(FlowPreview::class) @Composable fun VerticalGridFastScroller( state: LazyGridState, diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/util/Scrollbar.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/util/Scrollbar.kt index b842952e8..eebe3fd7a 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/util/Scrollbar.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/util/Scrollbar.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastSumBy +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest @@ -174,6 +175,7 @@ private fun ContentDrawScope.onDrawScrollbar( } } +@OptIn(FlowPreview::class) private fun Modifier.drawScrollbar( orientation: Orientation, reverseScrolling: Boolean,