Last Commit Merged: b4bb855675
This commit is contained in:
LuftVerbot 2023-10-05 14:16:41 +02:00
parent 2ebf477bd1
commit f705e19182
44 changed files with 661 additions and 681 deletions

View file

@ -37,4 +37,4 @@ jobs:
- name: Build app and run unit tests - name: Build app and run unit tests
uses: gradle/gradle-command-action@v2 uses: gradle/gradle-command-action@v2
with: with:
arguments: lintKotlin assembleStandardRelease testStandardReleaseUnitTest arguments: lintKotlin assembleStandardRelease testReleaseUnitTest

View file

@ -42,7 +42,7 @@ jobs:
- name: Build app and run unit tests - name: Build app and run unit tests
uses: gradle/gradle-command-action@v2 uses: gradle/gradle-command-action@v2
with: with:
arguments: lintKotlin assembleStandardRelease testStandardReleaseUnitTest arguments: lintKotlin assembleStandardRelease testReleaseUnitTest
# Sign APK and create release for tags # Sign APK and create release for tags

View file

@ -218,7 +218,6 @@ dependencies {
// Disk // Disk
implementation(libs.disklrucache) implementation(libs.disklrucache)
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.compress)
implementation(libs.junrar) implementation(libs.junrar)
// Preferences // Preferences

View file

@ -74,7 +74,4 @@
##---------------End: proguard configuration for kotlinx.serialization ---------- ##---------------End: proguard configuration for kotlinx.serialization ----------
# XmlUtil # XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; } -keep public enum nl.adaptivity.xmlutil.EventType { *; }
# org.apache.commons:commons-compress
-keep,allowoptimization class org.apache.commons.compress.archivers.zip.**

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
import eu.kanade.presentation.library.CommonEntryItemDefaults import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -41,10 +40,7 @@ fun BrowseAnimeSourceComfortableGrid(
} }
} }
items( items(count = animeList.itemCount) { index ->
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceComfortableGridItem( BrowseAnimeSourceComfortableGridItem(
anime = anime, anime = anime,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
import eu.kanade.presentation.library.CommonEntryItemDefaults import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -41,10 +40,7 @@ fun BrowseAnimeSourceCompactGrid(
} }
} }
items( items(count = animeList.itemCount) { index ->
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceCompactGridItem( BrowseAnimeSourceCompactGridItem(
anime = anime, anime = anime,

View file

@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import androidx.paging.compose.items import androidx.paging.compose.items
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem import eu.kanade.presentation.browse.manga.components.BrowseSourceLoadingItem
@ -35,10 +34,7 @@ fun BrowseAnimeSourceList(
} }
} }
items( items(count = animeList.itemCount) { index ->
count = animeList.itemCount,
key = animeList.itemKey { it.value.id },
) { index ->
val anime by animeList[index]?.collectAsState() ?: return@items val anime by animeList[index]?.collectAsState() ?: return@items
BrowseAnimeSourceListItem( BrowseAnimeSourceListItem(
anime = anime, anime = anime,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults import eu.kanade.presentation.library.CommonEntryItemDefaults
import eu.kanade.presentation.library.EntryComfortableGridItem import eu.kanade.presentation.library.EntryComfortableGridItem
@ -40,10 +39,7 @@ fun BrowseMangaSourceComfortableGrid(
} }
} }
items( items(count = mangaList.itemCount) { index ->
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceComfortableGridItem( BrowseMangaSourceComfortableGridItem(
manga = manga, manga = manga,

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults import eu.kanade.presentation.library.CommonEntryItemDefaults
import eu.kanade.presentation.library.EntryCompactGridItem import eu.kanade.presentation.library.EntryCompactGridItem
@ -40,10 +39,7 @@ fun BrowseMangaSourceCompactGrid(
} }
} }
items( items(count = mangaList.itemCount) { index ->
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceCompactGridItem( BrowseMangaSourceCompactGridItem(
manga = manga, manga = manga,

View file

@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import androidx.paging.compose.items import androidx.paging.compose.items
import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.library.CommonEntryItemDefaults import eu.kanade.presentation.library.CommonEntryItemDefaults
@ -34,10 +33,7 @@ fun BrowseMangaSourceList(
} }
} }
items( items(count = mangaList.itemCount) { index ->
count = mangaList.itemCount,
key = mangaList.itemKey { it.value.id },
) { index ->
val manga by mangaList[index]?.collectAsState() ?: return@items val manga by mangaList[index]?.collectAsState() ?: return@items
BrowseMangaSourceListItem( BrowseMangaSourceListItem(
manga = manga, manga = manga,

View file

@ -16,13 +16,15 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransition import cafe.adriel.voyager.transitions.ScreenTransition
import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.isTabletUi import eu.kanade.presentation.util.isTabletUi
import tachiyomi.presentation.core.components.AdaptiveSheet as AdaptiveSheetImpl import tachiyomi.presentation.core.components.AdaptiveSheet as AdaptiveSheetImpl
@OptIn(InternalVoyagerApi::class)
@Composable @Composable
fun NavigatorAdaptiveSheet( fun NavigatorAdaptiveSheet(
screen: Screen, screen: Screen,

View file

@ -20,6 +20,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@ -203,21 +204,36 @@ fun AppBarActions(
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
actions.filterIsInstance<AppBar.Action>().map { actions.filterIsInstance<AppBar.Action>().map {
IconButton( PlainTooltipBox(
onClick = it.onClick, tooltip = { Text(it.title) },
enabled = it.enabled,
) { ) {
Icon( IconButton(
imageVector = it.icon, onClick = it.onClick,
contentDescription = it.title, enabled = it.enabled,
) modifier = Modifier.tooltipAnchor(),
) {
Icon(
imageVector = it.icon,
contentDescription = it.title,
)
}
} }
} }
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>() val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) { if (overflowActions.isNotEmpty()) {
IconButton(onClick = { showMenu = !showMenu }) { PlainTooltipBox(
Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.abc_action_menu_overflow_description)) tooltip = { Text(stringResource(R.string.abc_action_menu_overflow_description)) },
) {
IconButton(
onClick = { showMenu = !showMenu },
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.abc_action_menu_overflow_description),
)
}
} }
DropdownMenu( DropdownMenu(
@ -327,17 +343,35 @@ fun SearchToolbar(
if (!searchEnabled) { if (!searchEnabled) {
// Don't show search action // Don't show search action
} else if (searchQuery == null) { } else if (searchQuery == null) {
IconButton(onClick) { PlainTooltipBox(
Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search)) tooltip = { Text(stringResource(R.string.action_search)) },
) {
IconButton(
onClick = onClick,
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.Search,
contentDescription = stringResource(R.string.action_search),
)
}
} }
} else if (searchQuery.isNotEmpty()) { } else if (searchQuery.isNotEmpty()) {
IconButton( PlainTooltipBox(
onClick = { tooltip = { Text(stringResource(R.string.action_reset)) },
onClick()
focusRequester.requestFocus()
},
) { ) {
Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.action_reset)) IconButton(
onClick = {
onClick()
focusRequester.requestFocus()
},
modifier = Modifier.tooltipAnchor(),
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_reset),
)
}
} }
} }
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -31,7 +32,6 @@ import kotlinx.coroutines.launch
import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.material.Divider import tachiyomi.presentation.core.components.material.Divider
import tachiyomi.presentation.core.components.material.TabIndicator import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.rememberPagerState
object TabbedDialogPaddings { object TabbedDialogPaddings {
val Horizontal = 24.dp val Horizontal = 24.dp
@ -84,7 +84,7 @@ fun TabbedDialog(
HorizontalPager( HorizontalPager(
modifier = Modifier.animateContentSize(), modifier = Modifier.animateContentSize(),
count = tabTitles.size, pageCount = tabTitles.size,
state = pagerState, state = pagerState,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { page -> ) { page ->

View file

@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -25,11 +27,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.TabIndicator import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.material.TabText import tachiyomi.presentation.core.components.material.TabText
import tachiyomi.presentation.core.components.rememberPagerState
@Composable @Composable
fun TabbedScreen( fun TabbedScreen(
@ -105,7 +105,7 @@ fun TabbedScreen(
} }
HorizontalPager( HorizontalPager(
count = tabs.size, pageCount = tabs.size,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = state, state = state,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.EntryDownloadDropdownMenu
import eu.kanade.presentation.components.OverflowMenu import eu.kanade.presentation.components.OverflowMenu
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -77,18 +79,20 @@ fun EntryToolbar(
}, },
actions = { actions = {
if (isActionMode) { if (isActionMode) {
IconButton(onClick = onSelectAll) { AppBarActions(
Icon( listOf(
imageVector = Icons.Outlined.SelectAll, AppBar.Action(
contentDescription = stringResource(R.string.action_select_all), title = stringResource(R.string.action_select_all),
) icon = Icons.Outlined.SelectAll,
} onClick = onSelectAll,
IconButton(onClick = onInvertSelection) { ),
Icon( AppBar.Action(
imageVector = Icons.Outlined.FlipToBack, title = stringResource(R.string.action_select_inverse),
contentDescription = stringResource(R.string.action_select_inverse), icon = Icons.Outlined.FlipToBack,
) onClick = onInvertSelection,
} ),
),
)
} else { } else {
if (onClickDownload != null) { if (onClickDownload != null) {
val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) } val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }

View file

@ -49,9 +49,9 @@ import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope import cafe.adriel.voyager.core.model.coroutineScope
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import eu.kanade.core.util.asFlow import eu.kanade.core.util.asFlow
import eu.kanade.presentation.components.TabbedDialogPaddings import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
@ -81,7 +81,7 @@ class EpisodeOptionsDialogScreen(
private val episodeId: Long, private val episodeId: Long,
private val animeId: Long, private val animeId: Long,
private val sourceId: Long, private val sourceId: Long,
) : Screen() { ) : Screen {
@Composable @Composable
override fun Content() { override fun Content() {

View file

@ -1,6 +1,7 @@
package eu.kanade.presentation.library package eu.kanade.presentation.library
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
@ -8,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.category.visualName import eu.kanade.presentation.category.visualName
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.components.material.Divider import tachiyomi.presentation.core.components.material.Divider
import tachiyomi.presentation.core.components.material.TabIndicator import tachiyomi.presentation.core.components.material.TabIndicator
import tachiyomi.presentation.core.components.material.TabText import tachiyomi.presentation.core.components.material.TabText

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.OverflowMenu import eu.kanade.presentation.components.OverflowMenu
import eu.kanade.presentation.components.SearchToolbar import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -145,12 +146,20 @@ fun LibrarySelectionToolbar(
AppBar( AppBar(
titleContent = { Text(text = "$selectedCount") }, titleContent = { Text(text = "$selectedCount") },
actions = { actions = {
IconButton(onClick = onClickSelectAll) { AppBarActions(
Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all)) listOf(
} AppBar.Action(
IconButton(onClick = onClickInvertSelection) { title = stringResource(R.string.action_select_all),
Icon(Icons.Outlined.FlipToBack, contentDescription = stringResource(R.string.action_select_inverse)) icon = Icons.Outlined.SelectAll,
} onClick = onClickSelectAll,
),
AppBar.Action(
title = stringResource(R.string.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onClickInvertSelection,
),
),
)
}, },
isActionMode = true, isActionMode = true,
onCancelActionMode = onClickUnselectAll, onCancelActionMode = onClickUnselectAll,

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -23,7 +24,6 @@ import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.anime.LibraryAnime import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.rememberPagerState
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
@ -61,8 +61,10 @@ fun AnimeLibraryContent(
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.size > 1) { if (showPageTabs && categories.size > 1) {
if (categories.size <= pagerState.currentPage) { LaunchedEffect(categories) {
pagerState.currentPage = categories.size - 1 if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)
}
} }
LibraryTabs( LibraryTabs(
categories = categories, categories = categories,

View file

@ -3,6 +3,7 @@ package eu.kanade.presentation.library.anime
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -16,7 +17,6 @@ import eu.kanade.tachiyomi.ui.library.anime.AnimeLibraryItem
import tachiyomi.domain.library.anime.LibraryAnime import tachiyomi.domain.library.anime.LibraryAnime
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
@Composable @Composable
fun AnimeLibraryPager( fun AnimeLibraryPager(
@ -35,7 +35,7 @@ fun AnimeLibraryPager(
onClickContinueWatching: ((LibraryAnime) -> Unit)?, onClickContinueWatching: ((LibraryAnime) -> Unit)?,
) { ) {
HorizontalPager( HorizontalPager(
count = pageCount, pageCount = pageCount,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = state, state = state,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -23,7 +24,6 @@ import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.manga.LibraryManga import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.rememberPagerState
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
@ -61,8 +61,10 @@ fun MangaLibraryContent(
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.size > 1) { if (showPageTabs && categories.size > 1) {
if (categories.size <= pagerState.currentPage) { LaunchedEffect(categories) {
pagerState.currentPage = categories.size - 1 if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)
}
} }
LibraryTabs( LibraryTabs(
categories = categories, categories = categories,

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,7 +24,6 @@ import eu.kanade.tachiyomi.ui.library.manga.MangaLibraryItem
import tachiyomi.domain.library.manga.LibraryManga import tachiyomi.domain.library.manga.LibraryManga
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.HorizontalPager
import tachiyomi.presentation.core.components.PagerState
import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
@ -44,7 +44,7 @@ fun MangaLibraryPager(
onClickContinueReading: ((LibraryManga) -> Unit)?, onClickContinueReading: ((LibraryManga) -> Unit)?,
) { ) {
HorizontalPager( HorizontalPager(
count = pageCount, pageCount = pageCount,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = state, state = state,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
@ -28,8 +29,11 @@ import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.presentation.util.ioCoroutineScope
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -63,13 +67,17 @@ object WorkerInfoScreen : Screen() {
} }
}, },
actions = { actions = {
IconButton( AppBarActions(
onClick = { listOf(
context.copyToClipboard(title, enqueued + finished + running) AppBar.Action(
}, title = stringResource(R.string.action_copy_to_clipboard),
) { icon = Icons.Default.ContentCopy,
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null) onClick = {
} context.copyToClipboard(title, enqueued + finished + running)
},
),
),
)
}, },
scrollBehavior = it, scrollBehavior = it,
) )

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.isTabletUi import eu.kanade.presentation.util.isTabletUi
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import kotlin.math.roundToInt
@Composable @Composable
fun ChapterNavigator( fun ChapterNavigator(
@ -107,7 +108,7 @@ fun ChapterNavigator(
valueRange = 1f..totalPages.toFloat(), valueRange = 1f..totalPages.toFloat(),
steps = totalPages - 2, steps = totalPages - 2,
onValueChange = { onValueChange = {
onSliderValueChange(it.toInt() - 1) onSliderValueChange(it.roundToInt() - 1)
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
) )

View file

@ -2,60 +2,59 @@ package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.OfflinePin import androidx.compose.material.icons.outlined.OfflinePin
import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.io.Files.append
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.manga.Chapter import eu.kanade.tachiyomi.data.database.models.manga.Chapter
import eu.kanade.tachiyomi.data.database.models.manga.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.manga.toDomainChapter import eu.kanade.tachiyomi.data.database.models.manga.toDomainChapter
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.entries.manga.model.Manga import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.domain.items.service.calculateChapterGap import tachiyomi.domain.items.service.calculateChapterGap
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha import tachiyomi.presentation.core.util.ThemePreviews
import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable @Composable
fun ChapterTransition( fun ChapterTransition(
transition: ChapterTransition, transition: ChapterTransition,
downloadManager: MangaDownloadManager, currChapterDownloaded: Boolean,
manga: Manga?, goingToChapterDownloaded: Boolean,
) { ) {
manga ?: return
val currChapter = transition.from.chapter val currChapter = transition.from.chapter
val currChapterDownloaded = transition.from.pageLoader?.isLocal == true
val goingToChapter = transition.to?.chapter val goingToChapter = transition.to?.chapter
val goingToChapterDownloaded = if (goingToChapter != null) {
downloadManager.isChapterDownloaded(
goingToChapter.name,
goingToChapter.scanlator,
manga.title,
manga.source,
skipCache = true,
)
} else {
false
}
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
when (transition) { when (transition) {
@ -90,80 +89,289 @@ fun ChapterTransition(
@Composable @Composable
private fun TransitionText( private fun TransitionText(
topLabel: String, topLabel: String,
topChapter: Chapter? = null, topChapter: Chapter?,
topChapterDownloaded: Boolean, topChapterDownloaded: Boolean,
bottomLabel: String, bottomLabel: String,
bottomChapter: Chapter? = null, bottomChapter: Chapter?,
bottomChapterDownloaded: Boolean, bottomChapterDownloaded: Boolean,
fallbackLabel: String, fallbackLabel: String,
chapterGap: Int, chapterGap: Int,
) { ) {
val hasTopChapter = topChapter != null Column(
val hasBottomChapter = bottomChapter != null modifier = Modifier
.widthIn(max = 460.dp)
.fillMaxWidth(),
) {
if (topChapter != null) {
ChapterText(
header = topLabel,
name = topChapter.name,
scanlator = topChapter.scanlator,
downloaded = topChapterDownloaded,
)
Column { Spacer(Modifier.height(VerticalSpacerSize))
Text( } else {
text = if (hasTopChapter) topLabel else fallbackLabel, NoChapterNotification(
fontWeight = FontWeight.Bold, text = fallbackLabel,
textAlign = if (hasTopChapter) TextAlign.Start else TextAlign.Center, modifier = Modifier.align(Alignment.CenterHorizontally),
) )
topChapter?.let { ChapterText(chapter = it, downloaded = topChapterDownloaded) }
Spacer(Modifier.height(16.dp))
if (chapterGap > 0) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
Text(text = pluralStringResource(R.plurals.missing_chapters_warning, count = chapterGap, chapterGap))
}
Spacer(Modifier.height(16.dp))
} }
Text( if (bottomChapter != null) {
text = if (hasBottomChapter) bottomLabel else fallbackLabel, if (chapterGap > 0) {
fontWeight = FontWeight.Bold, ChapterGapWarning(
textAlign = if (hasBottomChapter) TextAlign.Start else TextAlign.Center, gapCount = chapterGap,
) modifier = Modifier.align(Alignment.CenterHorizontally),
bottomChapter?.let { ChapterText(chapter = it, downloaded = bottomChapterDownloaded) } )
}
Spacer(Modifier.height(VerticalSpacerSize))
ChapterText(
header = bottomLabel,
name = bottomChapter.name,
scanlator = bottomChapter.scanlator,
downloaded = bottomChapterDownloaded,
)
} else {
NoChapterNotification(
text = fallbackLabel,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
} }
} }
@Composable @Composable
private fun ColumnScope.ChapterText( private fun NoChapterNotification(
chapter: Chapter, text: String,
downloaded: Boolean, modifier: Modifier = Modifier,
) { ) {
FlowRow( OutlinedCard(
verticalAlignment = Alignment.CenterVertically, modifier = modifier,
colors = CardColor,
) { ) {
if (downloaded) { Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon( Icon(
imageVector = Icons.Outlined.OfflinePin, imageVector = Icons.Outlined.Info,
contentDescription = stringResource(R.string.label_downloaded), tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
) )
Spacer(Modifier.width(8.dp)) Text(
} text = text,
style = MaterialTheme.typography.bodyMedium,
Text(chapter.name) )
} }
}
chapter.scanlator?.let { }
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy( @Composable
color = LocalContentColor.current.copy(alpha = SecondaryItemAlpha), private fun ChapterGapWarning(
), gapCount: Int,
) { modifier: Modifier = Modifier,
Text(it) ) {
OutlinedCard(
modifier = modifier,
colors = CardColor,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
Text(
text = pluralStringResource(R.plurals.missing_chapters_warning, count = gapCount, gapCount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ChapterHeaderText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier,
style = MaterialTheme.typography.titleMedium,
)
}
@Composable
private fun ChapterText(
header: String,
name: String,
scanlator: String?,
downloaded: Boolean,
) {
Column {
ChapterHeaderText(
text = header,
modifier = Modifier.padding(bottom = 4.dp),
)
Text(
text = buildAnnotatedString {
if (downloaded) {
appendInlineContent(DownloadedIconContentId)
append(' ')
}
append(name)
},
fontSize = 20.sp,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
inlineContent = mapOf(
DownloadedIconContentId to InlineTextContent(
Placeholder(
width = 22.sp,
height = 22.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
Icon(
imageVector = Icons.Outlined.OfflinePin,
contentDescription = stringResource(R.string.label_downloaded),
)
},
),
)
scanlator?.let {
Text(
text = it,
modifier = Modifier
.secondaryItemAlpha()
.padding(top = 2.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
private val CardColor: CardColors
@Composable
get() = CardDefaults.outlinedCardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
)
private val VerticalSpacerSize = 24.dp
private const val DownloadedIconContentId = "downloaded"
private fun previewChapter(name: String, scanlator: String, chapterNumber: Float) = ChapterImpl().apply {
this.name = name
this.scanlator = scanlator
this.chapter_number = chapterNumber
this.id = 0
this.manga_id = 0
this.url = ""
}
private val FakeChapter = previewChapter(
name = "Vol.1, Ch.1 - Fake Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 1f,
)
private val FakeGapChapter = previewChapter(
name = "Vol.5, Ch.44 - Fake Gap Chapter Title",
scanlator = "Scanlator Name",
chapterNumber = 44f,
)
private val FakeChapterLongTitle = previewChapter(
name = "Vol.1, Ch.0 - The Mundane Musings of a Metafictional Manga: A Chapter About a Chapter, Featuring" +
" an Absurdly Long Title and a Surprisingly Normal Day in the Lives of Our Heroes, as They Grapple with the " +
"Daily Challenges of Existence, from Paying Rent to Finding Love, All While Navigating the Strange World of " +
"Fictional Realities and Reality-Bending Fiction, Where the Fourth Wall is Always in Danger of Being Broken " +
"and the Line Between Author and Character is Forever Blurred.",
scanlator = "Long Long Funny Scanlator Sniper Group Name Reborn",
chapterNumber = 1f,
)
@ThemePreviews
@Composable
private fun TransitionTextPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), ReaderChapter(FakeChapter)),
currChapterDownloaded = false,
goingToChapterDownloaded = true,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextLongTitlePreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapterLongTitle), ReaderChapter(FakeChapter)),
currChapterDownloaded = true,
goingToChapterDownloaded = true,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextWithGapPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), ReaderChapter(FakeGapChapter)),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextNoNextPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Next(ReaderChapter(FakeChapter), null),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
}
}
}
@ThemePreviews
@Composable
private fun TransitionTextNoPreviousPreview() {
TachiyomiTheme {
Surface(modifier = Modifier.padding(48.dp)) {
ChapterTransition(
transition = ChapterTransition.Prev(ReaderChapter(FakeChapter), null),
currChapterDownloaded = true,
goingToChapterDownloaded = false,
)
} }
} }
} }

View file

@ -31,8 +31,6 @@ abstract class Tab : cafe.adriel.voyager.navigator.tab.Tab {
open suspend fun onReselect(navigator: Navigator) {} open suspend fun onReselect(navigator: Navigator) {}
} }
// TODO: this prevents crashes in nested navigators with transitions not being disposed
// properly. Go back to using vanilla Voyager Screens once fixed upstream.
abstract class Screen : Screen { abstract class Screen : Screen {
override val key: ScreenKey = uniqueScreenKey override val key: ScreenKey = uniqueScreenKey

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
@ -22,6 +23,8 @@ import tachiyomi.domain.backup.service.BackupPreferences
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) : class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) { CoroutineWorker(context, workerParams) {
@ -29,12 +32,14 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
private val notifier = BackupNotifier(context) private val notifier = BackupNotifier(context)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)
if (isAutoBackup && BackupRestoreJob.isRunning(context)) return Result.retry()
val backupPreferences = Injekt.get<BackupPreferences>() val backupPreferences = Injekt.get<BackupPreferences>()
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: backupPreferences.backupsDirectory().get().toUri() ?: backupPreferences.backupsDirectory().get().toUri()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL) val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL)
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)
try { try {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
@ -79,6 +84,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
10, 10,
TimeUnit.MINUTES, TimeUnit.MINUTES,
) )
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10.minutes.toJavaDuration())
.addTag(TAG_AUTO) .addTag(TAG_AUTO)
.setInputData( .setInputData(
workDataOf( workDataOf(

View file

@ -111,7 +111,6 @@ class BackupManager(
* @param uri path of Uri * @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job * @param isAutoBackup backup called from scheduled backup job
*/ */
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String { suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
throw IllegalStateException(context.getString(R.string.missing_storage_permission)) throw IllegalStateException(context.getString(R.string.missing_storage_permission))
@ -446,10 +445,10 @@ class BackupManager(
} }
internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga {
var manga = manga.copy(id = dbManga._id) var updatedManga = manga.copy(id = dbManga._id)
manga = manga.copyFrom(dbManga) updatedManga = updatedManga.copyFrom(dbManga)
updateManga(manga) updateManga(updatedManga)
return manga return updatedManga
} }
/** /**
@ -466,10 +465,10 @@ class BackupManager(
} }
internal suspend fun restoreExistingAnime(anime: Anime, dbAnime: Animes): Anime { internal suspend fun restoreExistingAnime(anime: Anime, dbAnime: Animes): Anime {
var anime = anime.copy(id = dbAnime._id) var updatedAnime = anime.copy(id = dbAnime._id)
anime = anime.copyFrom(dbAnime) updatedAnime = updatedAnime.copyFrom(dbAnime)
updateAnime(anime) updateAnime(updatedAnime)
return anime return updatedAnime
} }
/** /**
@ -582,7 +581,7 @@ class BackupManager(
dbCategories.firstOrNull { dbCategory -> dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name dbCategory.name == backupCategory.name
}?.let { dbCategory -> }?.let { dbCategory ->
mangaCategoriesToUpdate.add(Pair(manga.id!!, dbCategory.id)) mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id))
} }
} }
} }
@ -590,7 +589,7 @@ class BackupManager(
// Update database // Update database
if (mangaCategoriesToUpdate.isNotEmpty()) { if (mangaCategoriesToUpdate.isNotEmpty()) {
mangaHandler.await(true) { mangaHandler.await(true) {
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id!!) mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
mangas_categoriesQueries.insert(mangaId, categoryId) mangas_categoriesQueries.insert(mangaId, categoryId)
} }
@ -615,7 +614,7 @@ class BackupManager(
dbCategories.firstOrNull { dbCategory -> dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name dbCategory.name == backupCategory.name
}?.let { dbCategory -> }?.let { dbCategory ->
animeCategoriesToUpdate.add(Pair(anime.id!!, dbCategory.id)) animeCategoriesToUpdate.add(Pair(anime.id, dbCategory.id))
} }
} }
} }
@ -623,7 +622,7 @@ class BackupManager(
// Update database // Update database
if (animeCategoriesToUpdate.isNotEmpty()) { if (animeCategoriesToUpdate.isNotEmpty()) {
animeHandler.await(true) { animeHandler.await(true) {
animes_categoriesQueries.deleteAnimeCategoryByAnimeId(anime.id!!) animes_categoriesQueries.deleteAnimeCategoryByAnimeId(anime.id)
animeCategoriesToUpdate.forEach { (animeId, categoryId) -> animeCategoriesToUpdate.forEach { (animeId, categoryId) ->
animes_categoriesQueries.insert(animeId, categoryId) animes_categoriesQueries.insert(animeId, categoryId)
} }
@ -730,37 +729,38 @@ class BackupManager(
* @param tracks the track list to restore. * @param tracks the track list to restore.
*/ */
internal suspend fun restoreTracking(manga: Manga, tracks: List<tachiyomi.domain.track.manga.model.MangaTrack>) { internal suspend fun restoreTracking(manga: Manga, tracks: List<tachiyomi.domain.track.manga.model.MangaTrack>) {
// Fix foreign keys with the current manga id
val tracks = tracks.map { it.copy(mangaId = manga.id!!) }
// Get tracks from database // Get tracks from database
val dbTracks = mangaHandler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id!!) } val dbTracks = mangaHandler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>() val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.manga.model.MangaTrack>() val toInsert = mutableListOf<tachiyomi.domain.track.manga.model.MangaTrack>()
tracks.forEach { track -> tracks
var isInDatabase = false // Fix foreign keys with the current manga id
for (dbTrack in dbTracks) { .map { it.copy(mangaId = manga.id) }
if (track.syncId == dbTrack.sync_id) { .forEach { track ->
// The sync is already in the db, only update its fields var isInDatabase = false
var temp = dbTrack for (dbTrack in dbTracks) {
if (track.remoteId != dbTrack.remote_id) { if (track.syncId == dbTrack.sync_id) {
temp = temp.copy(remote_id = track.remoteId) // The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead))
isInDatabase = true
toUpdate.add(temp)
break
} }
if (track.libraryId != dbTrack.library_id) { }
temp = temp.copy(library_id = track.libraryId) if (!isInDatabase) {
} // Insert new sync. Let the db assign the id
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) toInsert.add(track.copy(id = 0))
isInDatabase = true
toUpdate.add(temp)
break
} }
} }
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database // Update database
if (toUpdate.isNotEmpty()) { if (toUpdate.isNotEmpty()) {
mangaHandler.await(true) { mangaHandler.await(true) {
@ -812,37 +812,38 @@ class BackupManager(
* @param tracks the track list to restore. * @param tracks the track list to restore.
*/ */
internal suspend fun restoreAnimeTracking(anime: Anime, tracks: List<tachiyomi.domain.track.anime.model.AnimeTrack>) { internal suspend fun restoreAnimeTracking(anime: Anime, tracks: List<tachiyomi.domain.track.anime.model.AnimeTrack>) {
// Fix foreign keys with the current anime id
val tracks = tracks.map { it.copy(animeId = anime.id!!) }
// Get tracks from database // Get tracks from database
val dbTracks = animeHandler.awaitList { anime_syncQueries.getTracksByAnimeId(anime.id!!) } val dbTracks = animeHandler.awaitList { anime_syncQueries.getTracksByAnimeId(anime.id) }
val toUpdate = mutableListOf<Anime_sync>() val toUpdate = mutableListOf<Anime_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.anime.model.AnimeTrack>() val toInsert = mutableListOf<tachiyomi.domain.track.anime.model.AnimeTrack>()
tracks.forEach { track -> tracks
var isInDatabase = false // Fix foreign keys with the current manga id
for (dbTrack in dbTracks) { .map { it.copy(animeId = anime.id) }
if (track.syncId == dbTrack.sync_id) { .forEach { track ->
// The sync is already in the db, only update its fields var isInDatabase = false
var temp = dbTrack for (dbTrack in dbTracks) {
if (track.remoteId != dbTrack.remote_id) { if (track.syncId == dbTrack.sync_id) {
temp = temp.copy(remote_id = track.remoteId) // The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_episode_seen = max(dbTrack.last_episode_seen, track.lastEpisodeSeen))
isInDatabase = true
toUpdate.add(temp)
break
} }
if (track.libraryId != dbTrack.library_id) { }
temp = temp.copy(library_id = track.libraryId) if (!isInDatabase) {
} // Insert new sync. Let the db assign the id
temp = temp.copy(last_episode_seen = max(dbTrack.last_episode_seen, track.lastEpisodeSeen)) toInsert.add(track.copy(id = 0))
isInDatabase = true
toUpdate.add(temp)
break
} }
} }
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database // Update database
if (toUpdate.isNotEmpty()) { if (toUpdate.isNotEmpty()) {
animeHandler.await(true) { animeHandler.await(true) {
@ -891,22 +892,22 @@ class BackupManager(
val dbChapters = mangaHandler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id!!) } val dbChapters = mangaHandler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id!!) }
val processed = chapters.map { chapter -> val processed = chapters.map { chapter ->
var chapter = chapter var updatedChapter = chapter
val dbChapter = dbChapters.find { it.url == chapter.url } val dbChapter = dbChapters.find { it.url == updatedChapter.url }
if (dbChapter != null) { if (dbChapter != null) {
chapter = chapter.copy(id = dbChapter._id) updatedChapter = updatedChapter.copy(id = dbChapter._id)
chapter = chapter.copyFrom(dbChapter) updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) { if (dbChapter.read && !updatedChapter.read) {
chapter = chapter.copy(read = dbChapter.read, lastPageRead = dbChapter.last_page_read) updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read)
} else if (chapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { } else if (chapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
chapter = chapter.copy(lastPageRead = dbChapter.last_page_read) updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
} }
if (!chapter.bookmark && dbChapter.bookmark) { if (!updatedChapter.bookmark && dbChapter.bookmark) {
chapter = chapter.copy(bookmark = dbChapter.bookmark) updatedChapter = updatedChapter.copy(bookmark = true)
} }
} }
chapter.copy(mangaId = manga.id ?: -1) updatedChapter.copy(mangaId = manga.id ?: -1)
} }
val newChapters = processed.groupBy { it.id > 0 } val newChapters = processed.groupBy { it.id > 0 }
@ -918,22 +919,22 @@ class BackupManager(
val dbEpisodes = animeHandler.awaitList { episodesQueries.getEpisodesByAnimeId(anime.id!!) } val dbEpisodes = animeHandler.awaitList { episodesQueries.getEpisodesByAnimeId(anime.id!!) }
val processed = episodes.map { episode -> val processed = episodes.map { episode ->
var episode = episode var updatedEpisode = episode
val dbEpisode = dbEpisodes.find { it.url == episode.url } val dbEpisode = dbEpisodes.find { it.url == updatedEpisode.url }
if (dbEpisode != null) { if (dbEpisode != null) {
episode = episode.copy(id = dbEpisode._id) updatedEpisode = updatedEpisode.copy(id = dbEpisode._id)
episode = episode.copyFrom(dbEpisode) updatedEpisode = updatedEpisode.copyFrom(dbEpisode)
if (dbEpisode.seen && !episode.seen) { if (dbEpisode.seen && !updatedEpisode.seen) {
episode = episode.copy(seen = dbEpisode.seen, lastSecondSeen = dbEpisode.last_second_seen) updatedEpisode = updatedEpisode.copy(seen = true, lastSecondSeen = dbEpisode.last_second_seen)
} else if (episode.lastSecondSeen == 0L && dbEpisode.last_second_seen != 0L) { } else if (updatedEpisode.lastSecondSeen == 0L && dbEpisode.last_second_seen != 0L) {
episode = episode.copy(lastSecondSeen = dbEpisode.last_second_seen) updatedEpisode = updatedEpisode.copy(lastSecondSeen = dbEpisode.last_second_seen)
} }
if (!episode.bookmark && dbEpisode.bookmark) { if (!updatedEpisode.bookmark && dbEpisode.bookmark) {
episode = episode.copy(bookmark = dbEpisode.bookmark) updatedEpisode = updatedEpisode.copy(bookmark = true)
} }
} }
episode.copy(animeId = anime.id ?: -1) updatedEpisode.copy(animeId = anime.id ?: -1)
} }
val newEpisodes = processed.groupBy { it.id > 0 } val newEpisodes = processed.groupBy { it.id > 0 }
@ -1126,55 +1127,6 @@ class BackupManager(
} }
} }
/**
* Updates a list of chapters
*/
private suspend fun updateChapters(chapters: List<tachiyomi.domain.items.chapter.model.Chapter>) {
mangaHandler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.update(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read.toLong(),
chapter.bookmark.toLong(),
chapter.lastPageRead,
chapter.chapterNumber.toDouble(),
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
chapter.id,
)
}
}
}
/**
* Updates a list of episodes
*/
private suspend fun updateEpisodes(episodes: List<tachiyomi.domain.items.episode.model.Episode>) {
animeHandler.await(true) {
episodes.forEach { episode ->
episodesQueries.update(
episode.animeId,
episode.url,
episode.name,
episode.scanlator,
episode.seen.toLong(),
episode.bookmark.toLong(),
episode.lastSecondSeen,
episode.totalSeconds,
episode.episodeNumber.toDouble(),
episode.sourceOrder,
episode.dateFetch,
episode.dateUpload,
episode.id,
)
}
}
}
/** /**
* Updates a list of chapters with known database ids * Updates a list of chapters with known database ids
*/ */

View file

@ -28,6 +28,7 @@ data class BackupAnimeTracking(
@ProtoNumber(11) var finishedWatchingDate: Long = 0, @ProtoNumber(11) var finishedWatchingDate: Long = 0,
@ProtoNumber(100) var mediaId: Long = 0, @ProtoNumber(100) var mediaId: Long = 0,
) { ) {
@Suppress("DEPRECATION")
fun getTrackingImpl(): AnimeTrack { fun getTrackingImpl(): AnimeTrack {
return AnimeTrack( return AnimeTrack(
id = -1, id = -1,

View file

@ -29,6 +29,7 @@ data class BackupTracking(
@ProtoNumber(100) var mediaId: Long = 0, @ProtoNumber(100) var mediaId: Long = 0,
) { ) {
@Suppress("DEPRECATION")
fun getTrackingImpl(): MangaTrack { fun getTrackingImpl(): MangaTrack {
return MangaTrack( return MangaTrack(
id = -1, id = -1,

View file

@ -118,7 +118,6 @@ class AnimeCoverScreenModel(
fun editCover(context: Context, data: Uri) { fun editCover(context: Context, data: Uri) {
val anime = state.value ?: return val anime = state.value ?: return
coroutineScope.launchIO { coroutineScope.launchIO {
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openInputStream(data)?.use { context.contentResolver.openInputStream(data)?.use {
try { try {
anime.editCover(Injekt.get(), it, updateAnime, coverCache) anime.editCover(Injekt.get(), it, updateAnime, coverCache)

View file

@ -628,8 +628,8 @@ class AnimeInfoScreenModel(
downloadEpisodes(episodes, false, video) downloadEpisodes(episodes, false, video)
} }
if (!isFavorited && !successState.hasPromptedToAddBefore) { if (!isFavorited && !successState.hasPromptedToAddBefore) {
updateSuccessState { successState -> updateSuccessState { state ->
successState.copy(hasPromptedToAddBefore = true) state.copy(hasPromptedToAddBefore = true)
} }
coroutineScope.launch { coroutineScope.launch {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(

View file

@ -118,7 +118,6 @@ class MangaCoverScreenModel(
fun editCover(context: Context, data: Uri) { fun editCover(context: Context, data: Uri) {
val manga = state.value ?: return val manga = state.value ?: return
coroutineScope.launchIO { coroutineScope.launchIO {
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openInputStream(data)?.use { context.contentResolver.openInputStream(data)?.use {
try { try {
manga.editCover(Injekt.get(), it, updateManga, coverCache) manga.editCover(Injekt.get(), it, updateManga, coverCache)

View file

@ -622,8 +622,8 @@ class MangaInfoScreenModel(
downloadChapters(chapters) downloadChapters(chapters)
} }
if (!isFavorited && !successState.hasPromptedToAddBefore) { if (!isFavorited && !successState.hasPromptedToAddBefore) {
updateSuccessState { successState -> updateSuccessState { state ->
successState.copy(hasPromptedToAddBefore = true) state.copy(hasPromptedToAddBefore = true)
} }
coroutineScope.launch { coroutineScope.launch {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(

View file

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import com.github.junrar.Archive import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import uy.kohesive.injekt.injectLazy import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import tachiyomi.core.util.system.ImageUtil
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.PipedInputStream import java.io.PipedInputStream
@ -15,36 +16,30 @@ import java.io.PipedOutputStream
*/ */
internal class RarPageLoader(file: File) : PageLoader() { internal class RarPageLoader(file: File) : PageLoader() {
private val context: Application by injectLazy() private val rar = Archive(file)
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
it.mkdirs()
}
init {
Archive(file).use { rar ->
rar.fileHeaders.asSequence()
.filterNot { it.isDirectory }
.forEach { header ->
val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
getStream(rar, header).use {
it.copyTo(pageOutputStream)
}
}
}
}
override var isLocal: Boolean = true override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
return DirectoryPageLoader(tmpDir).getPages() return rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
ReaderPage(i).apply {
stream = { getStream(rar, header) }
status = Page.State.READY
}
}
.toList()
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
} }
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
tmpDir.deleteRecursively() rar.close()
} }
/** /**

View file

@ -1,52 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application import android.os.Build
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import org.apache.commons.compress.archivers.zip.ZipFile import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import tachiyomi.core.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
/** /**
* Loader used to load a chapter from a .zip or .cbz file. * Loader used to load a chapter from a .zip or .cbz file.
*/ */
internal class ZipPageLoader(file: File) : PageLoader() { internal class ZipPageLoader(file: File) : PageLoader() {
private val context: Application by injectLazy() private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also { ZipFile(file, StandardCharsets.ISO_8859_1)
it.deleteRecursively() } else {
it.mkdirs() ZipFile(file)
}
init {
ByteArrayOutputStream().use { byteArrayOutputStream ->
FileInputStream(file).use { it.copyTo(byteArrayOutputStream) }
ZipFile(SeekableInMemoryByteChannel(byteArrayOutputStream.toByteArray())).use { zip ->
zip.entries.asSequence()
.filterNot { it.isDirectory }
.forEach { entry ->
File(tmpDir, entry.name.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream().use { pageOutputStream ->
zip.getInputStream(entry).copyTo(pageOutputStream)
pageOutputStream.flush()
}
}
}
}
} }
override var isLocal: Boolean = true override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
return DirectoryPageLoader(tmpDir).getPages() return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
status = Page.State.READY
}
}
.toList()
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
} }
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
tmpDir.deleteRecursively() zip.close()
} }
} }

View file

@ -2,35 +2,70 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.platform.ComposeView import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.util.view.setComposeContent
import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.Manga
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FrameLayout(context, attrs) { AbstractComposeView(context, attrs) {
private var data: Data? by mutableStateOf(null)
init { init {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
fun bind(transition: ChapterTransition, downloadManager: MangaDownloadManager, manga: Manga?) { fun bind(transition: ChapterTransition, downloadManager: MangaDownloadManager, manga: Manga?) {
manga ?: return data = if (manga != null) {
Data(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = transition.to?.chapter?.let { goingToChapter ->
downloadManager.isChapterDownloaded(
chapterName = goingToChapter.name,
chapterScanlator = goingToChapter.scanlator,
mangaTitle = manga.title,
sourceId = manga.source,
skipCache = true,
)
} ?: false,
)
} else {
null
}
}
removeAllViews() @Composable
override fun Content() {
val transitionView = ComposeView(context).apply { data?.let {
setComposeContent { TachiyomiTheme {
ChapterTransition( CompositionLocalProvider(
transition = transition, LocalTextStyle provides MaterialTheme.typography.bodySmall,
downloadManager = downloadManager, LocalContentColor provides MaterialTheme.colorScheme.onBackground,
manga = manga, ) {
) ChapterTransition(
transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = it.goingToChapterDownloaded,
)
}
} }
} }
addView(transitionView)
} }
private data class Data(
val transition: ChapterTransition,
val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: Boolean,
)
} }

View file

@ -8,8 +8,8 @@ import eu.kanade.tachiyomi.animesource.model.Video
@Suppress("OverridingDeprecatedMember") @Suppress("OverridingDeprecatedMember")
class StubAnimeSource( class StubAnimeSource(
override val id: Long, override val id: Long,
override val name: String,
override val lang: String, override val lang: String,
override val name: String,
) : AnimeSource { ) : AnimeSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank() val isInvalid: Boolean = name.isBlank() || lang.isBlank()

View file

@ -9,8 +9,8 @@ import rx.Observable
@Suppress("OverridingDeprecatedMember") @Suppress("OverridingDeprecatedMember")
class StubMangaSource( class StubMangaSource(
override val id: Long, override val id: Long,
override val name: String,
override val lang: String, override val lang: String,
override val name: String,
) : MangaSource { ) : MangaSource {
val isInvalid: Boolean = name.isBlank() || lang.isBlank() val isInvalid: Boolean = name.isBlank() || lang.isBlank()

View file

@ -10,7 +10,7 @@ appcompat = "androidx.appcompat:appcompat:1.6.1"
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05" biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0" coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0"
corektx = "androidx.core:core-ktx:1.11.0-alpha03" corektx = "androidx.core:core-ktx:1.11.0-alpha04"
splashscreen = "androidx.core:core-splashscreen:1.0.0-alpha02" splashscreen = "androidx.core:core-splashscreen:1.0.0-alpha02"
recyclerview = "androidx.recyclerview:recyclerview:1.3.0" recyclerview = "androidx.recyclerview:recyclerview:1.3.0"
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01" viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"

View file

@ -1,5 +1,5 @@
[versions] [versions]
compiler = "1.4.4" compiler = "1.4.7"
compose-bom = "2023.03.00" compose-bom = "2023.03.00"
accompanist = "0.30.1" accompanist = "0.30.1"

View file

@ -1,5 +1,5 @@
[versions] [versions]
kotlin_version = "1.8.10" kotlin_version = "1.8.21"
serialization_version = "1.5.0" serialization_version = "1.5.0"
xml_serialization_version = "0.85.0" xml_serialization_version = "0.85.0"
@ -7,7 +7,7 @@ xml_serialization_version = "0.85.0"
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" } reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }
gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" } gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" }
coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.6.4" } coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.7.1" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }
coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava" } coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava" }

View file

@ -6,7 +6,7 @@ shizuku_version = "12.2.0"
sqlite = "2.3.1" sqlite = "2.3.1"
sqldelight = "1.5.5" sqldelight = "1.5.5"
leakcanary = "2.10" leakcanary = "2.10"
voyager = "1.0.0-rc07" voyager = "1.0.0-rc06"
richtext = "0.16.0" richtext = "0.16.0"
[libraries] [libraries]
@ -31,7 +31,6 @@ jsoup = "org.jsoup:jsoup:1.16.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2" disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:17bec43" unifile = "com.github.tachiyomiorg:unifile:17bec43"
compress = "org.apache.commons:commons-compress:1.23.0"
junrar = "com.github.junrar:junrar:7.5.4" junrar = "com.github.junrar:junrar:7.5.4"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
@ -54,14 +53,14 @@ natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" } richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" } richtext-m3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" }
material = "com.google.android.material:material:1.8.0" material = "com.google.android.material:material:1.9.0"
flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533" flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013533"
flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c8013533" flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c8013533"
photoview = "com.github.chrisbanes:PhotoView:2.3.0" photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1" insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-cascade = "me.saket.cascade:cascade-compose:2.0.0-rc02" compose-cascade = "me.saket.cascade:cascade-compose:2.0.0-rc02"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:0.12.1" compose-materialmotion = "io.github.fornewid:material-motion-compose-core:0.12.3"
compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
logcat = "com.squareup.logcat:logcat:0.1" logcat = "com.squareup.logcat:logcat:0.1"
@ -84,11 +83,11 @@ sqldelight-android-paging = { module = "com.squareup.sqldelight:android-paging3-
sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version.ref = "sqldelight" } sqldelight-gradle = { module = "com.squareup.sqldelight:gradle-plugin", version.ref = "sqldelight" }
junit = "org.junit.jupiter:junit-jupiter:5.9.3" junit = "org.junit.jupiter:junit-jupiter:5.9.3"
kotest-assertions = "io.kotest:kotest-assertions-core:5.6.1" kotest-assertions = "io.kotest:kotest-assertions-core:5.6.2"
voyager-navigator = { module = "ca.gosyer:voyager-navigator", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "ca.gosyer:voyager-tab-navigator", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "ca.gosyer:voyager-transitions", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0" kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0"

View file

@ -1,300 +1,59 @@
package tachiyomi.presentation.core.components package tachiyomi.presentation.core.components
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.unit.Dp
import androidx.compose.ui.util.fastMaxBy import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastSumBy
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.abs
/**
* Horizontal Pager with custom SnapFlingBehavior for a more natural swipe feeling
*/
@Composable @Composable
fun HorizontalPager( fun HorizontalPager(
count: Int, pageCount: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: PagerState = rememberPagerState(), state: PagerState = rememberPagerState(),
key: ((page: Int) -> Any)? = null, contentPadding: PaddingValues = PaddingValues(0.dp),
contentPadding: PaddingValues = PaddingValues(), pageSize: PageSize = PageSize.Fill,
beyondBoundsPageCount: Int = 0,
pageSpacing: Dp = 0.dp,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
userScrollEnabled: Boolean = true, userScrollEnabled: Boolean = true,
content: @Composable BoxScope.(page: Int) -> Unit, reverseLayout: Boolean = false,
key: ((index: Int) -> Any)? = null,
pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
Orientation.Horizontal,
),
pageContent: @Composable (page: Int) -> Unit,
) { ) {
Pager( androidx.compose.foundation.pager.HorizontalPager(
count = count, pageCount = pageCount,
modifier = modifier, modifier = modifier,
state = state, state = state,
isVertical = false,
key = key,
contentPadding = contentPadding, contentPadding = contentPadding,
pageSize = pageSize,
beyondBoundsPageCount = beyondBoundsPageCount,
pageSpacing = pageSpacing,
verticalAlignment = verticalAlignment, verticalAlignment = verticalAlignment,
flingBehavior = PagerDefaults.flingBehavior(
state = state,
pagerSnapDistance = PagerSnapDistance.atMost(0),
),
userScrollEnabled = userScrollEnabled, userScrollEnabled = userScrollEnabled,
content = content, reverseLayout = reverseLayout,
key = key,
pageNestedScrollConnection = pageNestedScrollConnection,
pageContent = pageContent,
) )
} }
@Composable
private fun Pager(
count: Int,
modifier: Modifier,
state: PagerState,
isVertical: Boolean,
key: ((page: Int) -> Any)?,
contentPadding: PaddingValues,
userScrollEnabled: Boolean,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
content: @Composable BoxScope.(page: Int) -> Unit,
) {
LaunchedEffect(count) {
state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0)
}
LaunchedEffect(state) {
snapshotFlow { state.mostVisiblePageLayoutInfo?.index }
.distinctUntilChanged()
.collect { state.updateCurrentPageBasedOnLazyListState() }
}
if (isVertical) {
LazyColumn(
modifier = modifier,
state = state.lazyListState,
contentPadding = contentPadding,
horizontalAlignment = horizontalAlignment,
verticalArrangement = Arrangement.aligned(verticalAlignment),
userScrollEnabled = userScrollEnabled,
flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState),
) {
items(
count = count,
key = key,
) { page ->
Box(
modifier = Modifier
.fillParentMaxHeight()
.wrapContentSize(),
) {
content(this, page)
}
}
}
} else {
LazyRow(
modifier = modifier,
state = state.lazyListState,
contentPadding = contentPadding,
verticalAlignment = verticalAlignment,
horizontalArrangement = Arrangement.aligned(horizontalAlignment),
userScrollEnabled = userScrollEnabled,
flingBehavior = rememberLazyListSnapFlingBehavior(lazyListState = state.lazyListState),
) {
items(
count = count,
key = key,
) { page ->
Box(
modifier = Modifier
.fillParentMaxWidth()
.wrapContentSize(),
) {
content(this, page)
}
}
}
}
}
@Composable
fun rememberPagerState(
initialPage: Int = 0,
) = rememberSaveable(saver = PagerState.Saver) {
PagerState(currentPage = initialPage)
}
@Stable
class PagerState(
currentPage: Int = 0,
) {
init { check(currentPage >= 0) { "currentPage cannot be less than zero" } }
val lazyListState = LazyListState(firstVisibleItemIndex = currentPage)
private val pageSize: Int
get() = visiblePages.firstOrNull()?.size ?: 0
private var _currentPage by mutableStateOf(currentPage)
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
private val visiblePages: List<LazyListItemInfo>
get() = layoutInfo.visibleItemsInfo
var currentPage: Int
get() = _currentPage
set(value) {
if (value != _currentPage) {
_currentPage = value
}
}
val mostVisiblePageLayoutInfo: LazyListItemInfo?
get() {
val layoutInfo = lazyListState.layoutInfo
return layoutInfo.visibleItemsInfo.fastMaxBy {
val start = maxOf(it.offset, 0)
val end = minOf(
it.offset + it.size,
layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding,
)
end - start
}
}
private val closestPageToSnappedPosition: LazyListItemInfo?
get() = visiblePages.fastMaxBy {
-abs(
calculateDistanceToDesiredSnapPosition(
layoutInfo,
it,
SnapAlignmentStartToStart,
),
)
}
val currentPageOffsetFraction: Float by derivedStateOf {
val currentPagePositionOffset = closestPageToSnappedPosition?.offset ?: 0
val pageUsedSpace = pageSize.toFloat()
if (pageUsedSpace == 0f) {
// Default to 0 when there's no info about the page size yet.
0f
} else {
((-currentPagePositionOffset) / (pageUsedSpace)).coerceIn(
MinPageOffset,
MaxPageOffset,
)
}
}
fun updateCurrentPageBasedOnLazyListState() {
mostVisiblePageLayoutInfo?.let {
currentPage = it.index
}
}
suspend fun animateScrollToPage(page: Int) {
lazyListState.animateScrollToItem(index = page)
}
suspend fun scrollToPage(page: Int) {
lazyListState.scrollToItem(index = page)
updateCurrentPageBasedOnLazyListState()
}
companion object {
val Saver: Saver<PagerState, *> = listSaver(
save = { listOf(it.currentPage) },
restore = { PagerState(it[0]) },
)
}
}
private const val MinPageOffset = -0.5f
private const val MaxPageOffset = 0.5f
internal val SnapAlignmentStartToStart: (layoutSize: Float, itemSize: Float) -> Float =
{ _, _ -> 0f }
// https://android.googlesource.com/platform/frameworks/support/+/refs/changes/78/2160778/35/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt
private fun lazyListSnapLayoutInfoProvider(
lazyListState: LazyListState,
positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize ->
layoutSize / 2f - itemSize / 2f
},
) = object : SnapLayoutInfoProvider {
private val layoutInfo: LazyListLayoutInfo
get() = lazyListState.layoutInfo
// Single page snapping is the default
override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f
override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange<Float> {
var lowerBoundOffset = Float.NEGATIVE_INFINITY
var upperBoundOffset = Float.POSITIVE_INFINITY
layoutInfo.visibleItemsInfo.fastForEach { item ->
val offset =
calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout)
// Find item that is closest to the center
if (offset <= 0 && offset > lowerBoundOffset) {
lowerBoundOffset = offset
}
// Find item that is closest to center, but after it
if (offset >= 0 && offset < upperBoundOffset) {
upperBoundOffset = offset
}
}
return lowerBoundOffset.rangeTo(upperBoundOffset)
}
override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) {
if (visibleItemsInfo.isNotEmpty()) {
visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
} else {
0f
}
}
}
@Composable
private fun rememberLazyListSnapFlingBehavior(lazyListState: LazyListState): FlingBehavior {
val snappingLayout = remember(lazyListState) { lazyListSnapLayoutInfoProvider(lazyListState) }
return rememberSnapFlingBehavior(snappingLayout)
}
private fun calculateDistanceToDesiredSnapPosition(
layoutInfo: LazyListLayoutInfo,
item: LazyListItemInfo,
positionInLayout: (layoutSize: Float, itemSize: Float) -> Float,
): Float {
val containerSize =
with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding }
val desiredDistance =
positionInLayout(containerSize.toFloat(), item.size.toFloat())
val itemCurrentPosition = item.offset
return itemCurrentPosition - desiredDistance
}
private val LazyListLayoutInfo.singleAxisViewportSize: Int
get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width