Jelajahi Sumber

Use Compose for Library screen (#7557)

- Move Pager to Compose
- Move AppBar to Compose
- Use Stable interface for state
- Use pills for no. of manga in category instead of (x)
Andreas 2 tahun lalu
induk
melakukan
2b8d1bcc02
21 mengubah file dengan 970 tambahan dan 684 penghapusan
  1. 3 0
      app/build.gradle.kts
  2. 2 2
      app/src/main/java/eu/kanade/presentation/components/Badges.kt
  3. 88 0
      app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt
  4. 38 0
      app/src/main/java/eu/kanade/presentation/components/Pill.kt
  5. 71 0
      app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt
  6. 32 0
      app/src/main/java/eu/kanade/presentation/library/LibraryState.kt
  7. 17 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt
  8. 18 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt
  9. 126 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt
  10. 18 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt
  11. 15 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt
  12. 96 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt
  13. 77 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt
  14. 188 0
      app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt
  15. 12 0
      app/src/main/java/eu/kanade/presentation/theme/Color.kt
  16. 0 208
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt
  17. 50 373
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt
  18. 111 63
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt
  19. 5 1
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  20. 0 36
      app/src/main/res/layout/library_controller.xml
  21. 3 1
      gradle/compose.versions.toml

+ 3 - 0
app/build.gradle.kts

@@ -161,6 +161,8 @@ dependencies {
     implementation(compose.accompanist.webview)
     implementation(compose.accompanist.swiperefresh)
     implementation(compose.accompanist.flowlayout)
+    implementation(compose.accompanist.pager.core)
+    implementation(compose.accompanist.pager.indicators)
 
     implementation(androidx.paging.runtime)
     implementation(androidx.paging.compose)
@@ -302,6 +304,7 @@ tasks {
             "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
             "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
             "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
+            "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi"
         )
     }
 

+ 2 - 2
app/src/main/java/eu/kanade/presentation/components/Badges.kt

@@ -38,8 +38,8 @@ fun Badge(
 ) {
     Box(
         modifier = Modifier
-            .background(color)
-            .clip(shape),
+            .clip(shape)
+            .background(color),
     ) {
         Text(
             text = text,

+ 88 - 0
app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt

@@ -195,3 +195,91 @@ private fun RowScope.Button(
         }
     }
 }
+
+@Composable
+fun LibraryBottomActionMenu(
+    visible: Boolean,
+    modifier: Modifier = Modifier,
+    onChangeCategoryClicked: (() -> Unit)?,
+    onMarkAsReadClicked: (() -> Unit)?,
+    onMarkAsUnreadClicked: (() -> Unit)?,
+    onDownloadClicked: (() -> Unit)?,
+    onDeleteClicked: (() -> Unit)?,
+) {
+    AnimatedVisibility(
+        visible = visible,
+        enter = expandVertically(expandFrom = Alignment.Bottom),
+        exit = shrinkVertically(shrinkTowards = Alignment.Bottom),
+    ) {
+        val scope = rememberCoroutineScope()
+        Surface(
+            modifier = modifier,
+            shape = MaterialTheme.shapes.large,
+            tonalElevation = 3.dp,
+        ) {
+            val haptic = LocalHapticFeedback.current
+            val confirm = remember { mutableStateListOf(false, false, false, false, false) }
+            var resetJob: Job? = remember { null }
+            val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
+                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+                (0 until 5).forEach { i -> confirm[i] = i == toConfirmIndex }
+                resetJob?.cancel()
+                resetJob = scope.launch {
+                    delay(1000)
+                    if (isActive) confirm[toConfirmIndex] = false
+                }
+            }
+            Row(
+                modifier = Modifier
+                    .navigationBarsPadding()
+                    .padding(horizontal = 8.dp, vertical = 12.dp),
+            ) {
+                if (onChangeCategoryClicked != null) {
+                    Button(
+                        title = stringResource(R.string.action_move_category),
+                        icon = Icons.Default.BookmarkAdd,
+                        toConfirm = confirm[0],
+                        onLongClick = { onLongClickItem(0) },
+                        onClick = onChangeCategoryClicked,
+                    )
+                }
+                if (onMarkAsReadClicked != null) {
+                    Button(
+                        title = stringResource(R.string.action_mark_as_read),
+                        icon = Icons.Default.DoneAll,
+                        toConfirm = confirm[1],
+                        onLongClick = { onLongClickItem(1) },
+                        onClick = onMarkAsReadClicked,
+                    )
+                }
+                if (onMarkAsUnreadClicked != null) {
+                    Button(
+                        title = stringResource(R.string.action_mark_as_unread),
+                        icon = Icons.Default.RemoveDone,
+                        toConfirm = confirm[2],
+                        onLongClick = { onLongClickItem(2) },
+                        onClick = onMarkAsUnreadClicked,
+                    )
+                }
+                if (onDownloadClicked != null) {
+                    Button(
+                        title = stringResource(R.string.action_download),
+                        icon = Icons.Outlined.Download,
+                        toConfirm = confirm[3],
+                        onLongClick = { onLongClickItem(3) },
+                        onClick = onDownloadClicked,
+                    )
+                }
+                if (onDeleteClicked != null) {
+                    Button(
+                        title = stringResource(R.string.action_delete),
+                        icon = Icons.Outlined.Delete,
+                        toConfirm = confirm[4],
+                        onLongClick = { onLongClickItem(4) },
+                        onClick = onDeleteClicked,
+                    )
+                }
+            }
+        }
+    }
+}

+ 38 - 0
app/src/main/java/eu/kanade/presentation/components/Pill.kt

@@ -0,0 +1,38 @@
+package eu.kanade.presentation.components
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Pill(
+    text: String,
+    modifier: Modifier = Modifier,
+    color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.background,
+    contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onBackground,
+    elevation: Dp = 1.dp,
+    fontSize: TextUnit = LocalTextStyle.current.fontSize,
+) {
+    androidx.compose.material3.Surface(
+        modifier = modifier
+            .padding(start = 4.dp)
+            .clip(RoundedCornerShape(100)),
+        color = color,
+        contentColor = contentColor,
+        tonalElevation = elevation,
+    ) {
+        Text(
+            text = text,
+            modifier = Modifier.padding(6.dp, 1.dp),
+            fontSize = fontSize,
+        )
+    }
+}

+ 71 - 0
app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt

@@ -0,0 +1,71 @@
+package eu.kanade.presentation.library
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import eu.kanade.presentation.components.LibraryBottomActionMenu
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.library.components.LibraryContent
+import eu.kanade.presentation.library.components.LibraryToolbar
+import eu.kanade.tachiyomi.ui.library.LibraryPresenter
+
+@Composable
+fun LibraryScreen(
+    presenter: LibraryPresenter,
+    onMangaClicked: (Long) -> Unit,
+    onGlobalSearchClicked: () -> Unit,
+    onChangeCategoryClicked: () -> Unit,
+    onMarkAsReadClicked: () -> Unit,
+    onMarkAsUnreadClicked: () -> Unit,
+    onDownloadClicked: () -> Unit,
+    onDeleteClicked: () -> Unit,
+    onClickUnselectAll: () -> Unit,
+    onClickSelectAll: () -> Unit,
+    onClickInvertSelection: () -> Unit,
+    onClickFilter: () -> Unit,
+    onClickRefresh: () -> Unit,
+) {
+    Scaffold(
+        topBar = {
+            val title by presenter.getToolbarTitle()
+            LibraryToolbar(
+                state = presenter,
+                title = title,
+                onClickUnselectAll = onClickUnselectAll,
+                onClickSelectAll = onClickSelectAll,
+                onClickInvertSelection = onClickInvertSelection,
+                onClickFilter = onClickFilter,
+                onClickRefresh = onClickRefresh,
+            )
+        },
+        bottomBar = {
+            LibraryBottomActionMenu(
+                visible = presenter.selectionMode,
+                onChangeCategoryClicked = onChangeCategoryClicked,
+                onMarkAsReadClicked = onMarkAsReadClicked,
+                onMarkAsUnreadClicked = onMarkAsUnreadClicked,
+                onDownloadClicked = onDownloadClicked,
+                onDeleteClicked = onDeleteClicked,
+            )
+        },
+    ) { paddingValues ->
+        LibraryContent(
+            state = presenter,
+            contentPadding = paddingValues,
+            currentPage = presenter.activeCategory,
+            isLibraryEmpty = presenter.loadedManga.isEmpty(),
+            showPageTabs = presenter.tabVisibility,
+            showMangaCount = presenter.mangaCountVisibility,
+            onChangeCurrentPage = { presenter.activeCategory = it },
+            onMangaClicked = onMangaClicked,
+            onToggleSelection = { presenter.toggleSelection(it) },
+            onRefresh = onClickRefresh,
+            onGlobalSearchClicked = onGlobalSearchClicked,
+            getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) },
+            getDisplayModeForPage = { presenter.getDisplayMode(index = it) },
+            getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) },
+            getLibraryForPage = { presenter.getMangaForCategory(page = it) },
+            isIncognitoMode = presenter.isIncognitoMode,
+            isDownloadOnly = presenter.isDownloadOnly,
+        )
+    }
+}

+ 32 - 0
app/src/main/java/eu/kanade/presentation/library/LibraryState.kt

@@ -0,0 +1,32 @@
+package eu.kanade.presentation.library
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import eu.kanade.domain.category.model.Category
+import eu.kanade.tachiyomi.data.database.models.LibraryManga
+
+@Stable
+interface LibraryState {
+    val isLoading: Boolean
+    val categories: List<Category>
+    var searchQuery: String?
+    val selection: List<LibraryManga>
+    val selectionMode: Boolean
+    var hasActiveFilters: Boolean
+}
+
+fun LibraryState(): LibraryState {
+    return LibraryStateImpl()
+}
+
+class LibraryStateImpl : LibraryState {
+    override var isLoading: Boolean by mutableStateOf(true)
+    override var categories: List<Category> by mutableStateOf(emptyList())
+    override var searchQuery: String? by mutableStateOf(null)
+    override var selection: List<LibraryManga> by mutableStateOf(emptyList())
+    override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() }
+    override var hasActiveFilters: Boolean by mutableStateOf(false)
+}

+ 17 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt

@@ -3,14 +3,19 @@ package eu.kanade.presentation.library.components
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.items
 import androidx.compose.material3.LocalTextStyle
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
 import eu.kanade.domain.manga.model.MangaCover
+import eu.kanade.presentation.components.TextButton
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.LibraryManga
 import eu.kanade.tachiyomi.ui.library.LibraryItem
 
@@ -21,10 +26,22 @@ fun LibraryComfortableGrid(
     selection: List<LibraryManga>,
     onClick: (LibraryManga) -> Unit,
     onLongClick: (LibraryManga) -> Unit,
+    searchQuery: String?,
+    onGlobalSearchClicked: () -> Unit,
 ) {
     LazyLibraryGrid(
         columns = columns,
     ) {
+        item(span = { GridItemSpan(maxLineSpan) }) {
+            if (searchQuery.isNullOrEmpty().not()) {
+                TextButton(onClick = onGlobalSearchClicked) {
+                    Text(
+                        text = stringResource(R.string.action_global_search_query, searchQuery!!),
+                        modifier = Modifier.zIndex(99f),
+                    )
+                }
+            }
+        }
         items(
             items = items,
             key = {

+ 18 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.items
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.LocalTextStyle
@@ -17,8 +18,12 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import eu.kanade.presentation.components.TextButton
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.LibraryManga
 import eu.kanade.tachiyomi.ui.library.LibraryItem
 
@@ -29,10 +34,23 @@ fun LibraryCompactGrid(
     selection: List<LibraryManga>,
     onClick: (LibraryManga) -> Unit,
     onLongClick: (LibraryManga) -> Unit,
+    searchQuery: String?,
+    onGlobalSearchClicked: () -> Unit,
 ) {
     LazyLibraryGrid(
         columns = columns,
     ) {
+        item(span = { GridItemSpan(maxLineSpan) }) {
+            if (searchQuery.isNullOrEmpty().not()) {
+                TextButton(onClick = onGlobalSearchClicked) {
+                    Text(
+                        text = stringResource(R.string.action_global_search_query, searchQuery!!),
+                        modifier = Modifier.zIndex(99f),
+                    )
+                }
+            }
+        }
+
         items(
             items = items,
             key = {

+ 126 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt

@@ -0,0 +1,126 @@
+package eu.kanade.presentation.library.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import com.google.accompanist.pager.rememberPagerState
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+import eu.kanade.core.prefs.PreferenceMutableState
+import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.LoadingScreen
+import eu.kanade.presentation.components.SwipeRefreshIndicator
+import eu.kanade.presentation.library.LibraryState
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.LibraryManga
+import eu.kanade.tachiyomi.ui.library.LibraryItem
+import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
+import eu.kanade.tachiyomi.util.system.openInBrowser
+import eu.kanade.tachiyomi.widget.EmptyView
+
+@Composable
+fun LibraryContent(
+    state: LibraryState,
+    contentPadding: PaddingValues,
+    currentPage: Int,
+    isLibraryEmpty: Boolean,
+    isDownloadOnly: Boolean,
+    isIncognitoMode: Boolean,
+    showPageTabs: Boolean,
+    showMangaCount: Boolean,
+    onChangeCurrentPage: (Int) -> Unit,
+    onMangaClicked: (Long) -> Unit,
+    onToggleSelection: (LibraryManga) -> Unit,
+    onRefresh: () -> Unit,
+    onGlobalSearchClicked: () -> Unit,
+    getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>,
+    getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>,
+    getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
+    getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>,
+) {
+    val nestedScrollInterop = rememberNestedScrollInteropConnection()
+
+    val pagerState = rememberPagerState(currentPage)
+
+    val categories = state.categories
+
+    if (categories.isEmpty()) {
+        LoadingScreen()
+        return
+    }
+
+    Column(
+        modifier = Modifier.padding(contentPadding),
+    ) {
+        if (showPageTabs && categories.size > 1) {
+            LibraryTabs(
+                state = pagerState,
+                categories = state.categories,
+                showMangaCount = showMangaCount,
+                getNumberOfMangaForCategory = getNumberOfMangaForCategory,
+                isDownloadOnly = isDownloadOnly,
+                isIncognitoMode = isIncognitoMode,
+            )
+        }
+
+        val onClickManga = { manga: LibraryManga ->
+            if (state.selectionMode.not()) {
+                onMangaClicked(manga.id!!)
+            } else {
+                onToggleSelection(manga)
+            }
+        }
+        val onLongClickManga = { manga: LibraryManga ->
+            onToggleSelection(manga)
+        }
+
+        SwipeRefresh(
+            state = rememberSwipeRefreshState(isRefreshing = false),
+            modifier = Modifier.nestedScroll(nestedScrollInterop),
+            onRefresh = onRefresh,
+            indicator = { s, trigger ->
+                SwipeRefreshIndicator(
+                    state = s,
+                    refreshTriggerDistance = trigger,
+                )
+            },
+        ) {
+            if (state.searchQuery.isNullOrEmpty() && isLibraryEmpty) {
+                val context = LocalContext.current
+                EmptyScreen(
+                    R.string.information_empty_library,
+                    listOf(
+                        EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) {
+                            context.openInBrowser("https://tachiyomi.org/help/guides/getting-started")
+                        },
+                    ),
+                )
+                return@SwipeRefresh
+            }
+
+            LibraryPager(
+                state = pagerState,
+                pageCount = categories.size,
+                selectedManga = state.selection,
+                getDisplayModeForPage = getDisplayModeForPage,
+                getColumnsForOrientation = getColumnsForOrientation,
+                getLibraryForPage = getLibraryForPage,
+                onClickManga = onClickManga,
+                onLongClickManga = onLongClickManga,
+                onGlobalSearchClicked = onGlobalSearchClicked,
+                searchQuery = state.searchQuery,
+            )
+        }
+
+        LaunchedEffect(pagerState.currentPage) {
+            onChangeCurrentPage(pagerState.currentPage)
+        }
+    }
+}

+ 18 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt

@@ -1,9 +1,15 @@
 package eu.kanade.presentation.library.components
 
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.zIndex
+import eu.kanade.presentation.components.TextButton
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.LibraryManga
 import eu.kanade.tachiyomi.ui.library.LibraryItem
 
@@ -14,10 +20,22 @@ fun LibraryCoverOnlyGrid(
     selection: List<LibraryManga>,
     onClick: (LibraryManga) -> Unit,
     onLongClick: (LibraryManga) -> Unit,
+    searchQuery: String?,
+    onGlobalSearchClicked: () -> Unit,
 ) {
     LazyLibraryGrid(
         columns = columns,
     ) {
+        item(span = { GridItemSpan(maxLineSpan) }) {
+            if (searchQuery.isNullOrEmpty().not()) {
+                TextButton(onClick = onGlobalSearchClicked) {
+                    Text(
+                        text = stringResource(R.string.action_global_search_query, searchQuery!!),
+                        modifier = Modifier.zIndex(99f),
+                    )
+                }
+            }
+        }
         items(
             items = items,
             key = {

+ 15 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt

@@ -17,9 +17,11 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
 import eu.kanade.domain.manga.model.MangaCover
 import eu.kanade.presentation.components.Badge
 import eu.kanade.presentation.components.BadgeGroup
+import eu.kanade.presentation.components.TextButton
 import eu.kanade.presentation.util.horizontalPadding
 import eu.kanade.presentation.util.selectedBackground
 import eu.kanade.presentation.util.verticalPadding
@@ -33,10 +35,23 @@ fun LibraryList(
     selection: List<LibraryManga>,
     onClick: (LibraryManga) -> Unit,
     onLongClick: (LibraryManga) -> Unit,
+    searchQuery: String?,
+    onGlobalSearchClicked: () -> Unit,
 ) {
     LazyColumn(
         contentPadding = WindowInsets.navigationBars.asPaddingValues(),
     ) {
+        item {
+            if (searchQuery.isNullOrEmpty().not()) {
+                TextButton(onClick = onGlobalSearchClicked) {
+                    Text(
+                        text = stringResource(R.string.action_global_search_query, searchQuery!!),
+                        modifier = Modifier.zIndex(99f),
+                    )
+                }
+            }
+        }
+
         items(
             items = items,
             key = {

+ 96 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt

@@ -0,0 +1,96 @@
+package eu.kanade.presentation.library.components
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import com.google.accompanist.pager.HorizontalPager
+import com.google.accompanist.pager.PagerState
+import eu.kanade.core.prefs.PreferenceMutableState
+import eu.kanade.tachiyomi.data.database.models.LibraryManga
+import eu.kanade.tachiyomi.ui.library.LibraryItem
+import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
+
+@Composable
+fun LibraryPager(
+    state: PagerState,
+    pageCount: Int,
+    selectedManga: List<LibraryManga>,
+    searchQuery: String?,
+    onGlobalSearchClicked: () -> Unit,
+    getDisplayModeForPage: @Composable (Int) -> State<DisplayModeSetting>,
+    getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
+    getLibraryForPage: @Composable (Int) -> State<List<LibraryItem>>,
+    onClickManga: (LibraryManga) -> Unit,
+    onLongClickManga: (LibraryManga) -> Unit,
+) {
+    HorizontalPager(
+        count = pageCount,
+        modifier = Modifier.fillMaxSize(),
+        state = state,
+        verticalAlignment = Alignment.Top,
+    ) { page ->
+        val library by getLibraryForPage(page)
+        val displayMode by getDisplayModeForPage(page)
+        val columns by if (displayMode != DisplayModeSetting.LIST) {
+            val configuration = LocalConfiguration.current
+            val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+            remember(isLandscape) { getColumnsForOrientation(isLandscape) }
+        } else {
+            remember { mutableStateOf(0) }
+        }
+
+        when (displayMode) {
+            DisplayModeSetting.LIST -> {
+                LibraryList(
+                    items = library,
+                    selection = selectedManga,
+                    onClick = onClickManga,
+                    onLongClick = onLongClickManga,
+                    searchQuery = searchQuery,
+                    onGlobalSearchClicked = onGlobalSearchClicked,
+                )
+            }
+            DisplayModeSetting.COMPACT_GRID -> {
+                LibraryCompactGrid(
+                    items = library,
+                    columns = columns,
+                    selection = selectedManga,
+                    onClick = onClickManga,
+                    onLongClick = onLongClickManga,
+                    searchQuery = searchQuery,
+                    onGlobalSearchClicked = onGlobalSearchClicked,
+                )
+            }
+            DisplayModeSetting.COMFORTABLE_GRID -> {
+                LibraryComfortableGrid(
+                    items = library,
+                    columns = columns,
+                    selection = selectedManga,
+                    onClick = onClickManga,
+                    onLongClick = onLongClickManga,
+                    searchQuery = searchQuery,
+                    onGlobalSearchClicked = onGlobalSearchClicked,
+                )
+            }
+            DisplayModeSetting.COVER_ONLY_GRID -> {
+                LibraryCoverOnlyGrid(
+                    items = library,
+                    columns = columns,
+                    selection = selectedManga,
+                    onClick = onClickManga,
+                    onLongClick = onLongClickManga,
+                    searchQuery = searchQuery,
+                    onGlobalSearchClicked = onGlobalSearchClicked,
+                )
+            }
+        }
+    }
+}

+ 77 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt

@@ -0,0 +1,77 @@
+package eu.kanade.presentation.library.components
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.accompanist.pager.PagerState
+import eu.kanade.domain.category.model.Category
+import eu.kanade.presentation.components.DownloadedOnlyModeBanner
+import eu.kanade.presentation.components.IncognitoModeBanner
+import eu.kanade.presentation.components.Pill
+import kotlinx.coroutines.launch
+
+@Composable
+fun LibraryTabs(
+    state: PagerState,
+    categories: List<Category>,
+    showMangaCount: Boolean,
+    isDownloadOnly: Boolean,
+    isIncognitoMode: Boolean,
+    getNumberOfMangaForCategory: @Composable (Long) -> State<Int?>,
+) {
+    val scope = rememberCoroutineScope()
+
+    val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
+
+    Column {
+        ScrollableTabRow(
+            selectedTabIndex = state.currentPage,
+            edgePadding = 0.dp,
+        ) {
+            categories.forEachIndexed { index, category ->
+                val count by if (showMangaCount) {
+                    getNumberOfMangaForCategory(category.id)
+                } else {
+                    remember { mutableStateOf<Int?>(null) }
+                }
+                Tab(
+                    selected = state.currentPage == index,
+                    onClick = { scope.launch { state.animateScrollToPage(index) } },
+                    text = {
+                        Row(
+                            verticalAlignment = Alignment.CenterVertically,
+                        ) {
+                            Text(text = category.name)
+                            if (count != null) {
+                                Pill(
+                                    text = "$count",
+                                    color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
+                                    fontSize = 10.sp,
+                                )
+                            }
+                        }
+                    },
+                )
+            }
+        }
+        if (isDownloadOnly) {
+            DownloadedOnlyModeBanner()
+        }
+        if (isIncognitoMode) {
+            IncognitoModeBanner()
+        }
+    }
+}

+ 188 - 0
app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt

@@ -0,0 +1,188 @@
+package eu.kanade.presentation.library.components
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material.icons.outlined.FlipToBack
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.SelectAll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SmallTopAppBar
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+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.text.style.TextOverflow
+import androidx.compose.ui.unit.sp
+import eu.kanade.presentation.components.Pill
+import eu.kanade.presentation.library.LibraryState
+import eu.kanade.presentation.theme.active
+import kotlinx.coroutines.delay
+
+@Composable
+fun LibraryToolbar(
+    state: LibraryState,
+    title: LibraryToolbarTitle,
+    onClickUnselectAll: () -> Unit,
+    onClickSelectAll: () -> Unit,
+    onClickInvertSelection: () -> Unit,
+    onClickFilter: () -> Unit,
+    onClickRefresh: () -> Unit,
+) = when {
+    state.searchQuery != null -> LibrarySearchToolbar(
+        searchQuery = state.searchQuery!!,
+        onChangeSearchQuery = { state.searchQuery = it },
+        onClickCloseSearch = { state.searchQuery = null },
+    )
+    state.selectionMode -> LibrarySelectionToolbar(
+        state = state,
+        onClickUnselectAll = onClickUnselectAll,
+        onClickSelectAll = onClickSelectAll,
+        onClickInvertSelection = onClickInvertSelection,
+    )
+    else -> LibraryRegularToolbar(
+        title = title,
+        hasFilters = state.hasActiveFilters,
+        onClickSearch = { state.searchQuery = "" },
+        onClickFilter = onClickFilter,
+        onClickRefresh = onClickRefresh,
+    )
+}
+
+@Composable
+fun LibraryRegularToolbar(
+    title: LibraryToolbarTitle,
+    hasFilters: Boolean,
+    onClickSearch: () -> Unit,
+    onClickFilter: () -> Unit,
+    onClickRefresh: () -> Unit,
+) {
+    val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
+    val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current
+    SmallTopAppBar(
+        modifier = Modifier.statusBarsPadding(),
+        title = {
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                Text(
+                    text = title.text,
+                    maxLines = 1,
+                    modifier = Modifier.weight(1f, false),
+                    overflow = TextOverflow.Ellipsis,
+                )
+                if (title.numberOfManga != null) {
+                    Pill(
+                        text = "${title.numberOfManga}",
+                        color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
+                        fontSize = 14.sp,
+                    )
+                }
+            }
+        },
+        actions = {
+            IconButton(onClick = onClickSearch) {
+                Icon(Icons.Outlined.Search, contentDescription = "search")
+            }
+            IconButton(onClick = onClickFilter) {
+                Icon(Icons.Outlined.FilterList, contentDescription = "search", tint = filterTint)
+            }
+            IconButton(onClick = onClickRefresh) {
+                Icon(Icons.Outlined.Refresh, contentDescription = "search")
+            }
+        },
+    )
+}
+
+@Composable
+fun LibrarySelectionToolbar(
+    state: LibraryState,
+    onClickUnselectAll: () -> Unit,
+    onClickSelectAll: () -> Unit,
+    onClickInvertSelection: () -> Unit,
+) {
+    val backgroundColor by TopAppBarDefaults.smallTopAppBarColors().containerColor(1f)
+    SmallTopAppBar(
+        modifier = Modifier
+            .drawBehind {
+                drawRect(backgroundColor.copy(alpha = 1f))
+            }
+            .statusBarsPadding(),
+        navigationIcon = {
+            IconButton(onClick = onClickUnselectAll) {
+                Icon(Icons.Outlined.Close, contentDescription = "close")
+            }
+        },
+        title = {
+            Text(text = "${state.selection.size}")
+        },
+        actions = {
+            IconButton(onClick = onClickSelectAll) {
+                Icon(Icons.Outlined.SelectAll, contentDescription = "search")
+            }
+            IconButton(onClick = onClickInvertSelection) {
+                Icon(Icons.Outlined.FlipToBack, contentDescription = "invert")
+            }
+        },
+        colors = TopAppBarDefaults.smallTopAppBarColors(
+            containerColor = Color.Transparent,
+            scrolledContainerColor = Color.Transparent,
+        ),
+    )
+}
+
+@Composable
+fun LibrarySearchToolbar(
+    searchQuery: String,
+    onChangeSearchQuery: (String) -> Unit,
+    onClickCloseSearch: () -> Unit,
+) {
+    val focusRequester = remember { FocusRequester.Default }
+    SmallTopAppBar(
+        modifier = Modifier.statusBarsPadding(),
+        navigationIcon = {
+            IconButton(onClick = onClickCloseSearch) {
+                Icon(Icons.Outlined.ArrowBack, contentDescription = "back")
+            }
+        },
+        title = {
+            BasicTextField(
+                value = searchQuery,
+                onValueChange = onChangeSearchQuery,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .focusRequester(focusRequester),
+                textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground),
+                singleLine = true,
+                cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
+            )
+            LaunchedEffect(focusRequester) {
+                // TODO: https://issuetracker.google.com/issues/204502668
+                delay(100)
+                focusRequester.requestFocus()
+            }
+        },
+    )
+}
+
+data class LibraryToolbarTitle(
+    val text: String,
+    val numberOfManga: Int? = null,
+)

+ 12 - 0
app/src/main/java/eu/kanade/presentation/theme/Color.kt

@@ -0,0 +1,12 @@
+package eu.kanade.presentation.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+val ColorScheme.active: Color
+    @Composable
+    get() {
+        return if (isSystemInDarkTheme()) Color(255, 235, 59) else Color(255, 193, 7)
+    }

+ 0 - 208
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt

@@ -1,208 +0,0 @@
-package eu.kanade.tachiyomi.ui.library
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
-import com.google.accompanist.swiperefresh.SwipeRefresh
-import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
-import eu.kanade.domain.category.model.Category
-import eu.kanade.presentation.components.SwipeRefreshIndicator
-import eu.kanade.presentation.library.components.LibraryComfortableGrid
-import eu.kanade.presentation.library.components.LibraryCompactGrid
-import eu.kanade.presentation.library.components.LibraryCoverOnlyGrid
-import eu.kanade.presentation.library.components.LibraryList
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.LibraryManga
-import eu.kanade.tachiyomi.data.library.LibraryUpdateService
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
-import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
-import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.util.view.setComposeContent
-import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-
-/**
- * This adapter stores the categories from the library, used with a ViewPager.
- *
- * @constructor creates an instance of the adapter.
- */
-class LibraryAdapter(
-    private val presenter: LibraryPresenter,
-    private val onClickManga: (LibraryManga) -> Unit,
-    private val preferences: PreferencesHelper = Injekt.get(),
-) : RecyclerViewPagerAdapter() {
-
-    /**
-     * The categories to bind in the adapter.
-     */
-    var categories: List<Category> = mutableStateListOf()
-        private set
-
-    /**
-     * The number of manga in each category.
-     * List order must be the same as [categories]
-     */
-    private var itemsPerCategory: List<Int> = emptyList()
-
-    private var boundViews = arrayListOf<View>()
-
-    /**
-     * Pair of category and size of category
-     */
-    fun updateCategories(new: List<Pair<Category, Int>>) {
-        var updated = false
-
-        val newCategories = new.map { it.first }
-        if (categories != newCategories) {
-            categories = newCategories
-            updated = true
-        }
-
-        val newItemsPerCategory = new.map { it.second }
-        if (itemsPerCategory !== newItemsPerCategory) {
-            itemsPerCategory = newItemsPerCategory
-            updated = true
-        }
-
-        if (updated) {
-            notifyDataSetChanged()
-        }
-    }
-
-    /**
-     * Creates a new view for this adapter.
-     *
-     * @return a new view.
-     */
-    override fun inflateView(container: ViewGroup, viewType: Int): View {
-        val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false)
-        return binding.root
-    }
-
-    /**
-     * Binds a view with a position.
-     *
-     * @param view the view to bind.
-     * @param position the position in the adapter.
-     */
-    override fun bindView(view: View, position: Int) {
-        (view as ComposeView).apply {
-            setComposeContent {
-                val nestedScrollInterop = rememberNestedScrollInteropConnection()
-
-                val category = presenter.categories[position]
-                val displayMode = presenter.getDisplayMode(index = position)
-                val mangaList by presenter.getMangaForCategory(categoryId = category.id)
-
-                val onClickManga = { manga: LibraryManga ->
-                    if (presenter.hasSelection().not()) {
-                        onClickManga(manga)
-                    } else {
-                        presenter.toggleSelection(manga)
-                    }
-                }
-                val onLongClickManga = { manga: LibraryManga ->
-                    presenter.toggleSelection(manga)
-                }
-
-                SwipeRefresh(
-                    modifier = Modifier.nestedScroll(nestedScrollInterop),
-                    state = rememberSwipeRefreshState(isRefreshing = false),
-                    onRefresh = {
-                        if (LibraryUpdateService.start(context, category)) {
-                            context.toast(R.string.updating_category)
-                        }
-                    },
-                    indicator = { s, trigger ->
-                        SwipeRefreshIndicator(
-                            state = s,
-                            refreshTriggerDistance = trigger,
-                        )
-                    },
-                ) {
-                    when (displayMode) {
-                        DisplayModeSetting.LIST -> {
-                            LibraryList(
-                                items = mangaList,
-                                selection = presenter.selection,
-                                onClick = onClickManga,
-                                onLongClick = onLongClickManga,
-                            )
-                        }
-                        DisplayModeSetting.COMPACT_GRID -> {
-                            LibraryCompactGrid(
-                                items = mangaList,
-                                columns = presenter.columns,
-                                selection = presenter.selection,
-                                onClick = onClickManga,
-                                onLongClick = onLongClickManga,
-                            )
-                        }
-                        DisplayModeSetting.COMFORTABLE_GRID -> {
-                            LibraryComfortableGrid(
-                                items = mangaList,
-                                columns = presenter.columns,
-                                selection = presenter.selection,
-                                onClick = onClickManga,
-                                onLongClick = onLongClickManga,
-                            )
-                        }
-                        DisplayModeSetting.COVER_ONLY_GRID -> {
-                            LibraryCoverOnlyGrid(
-                                items = mangaList,
-                                columns = presenter.columns,
-                                selection = presenter.selection,
-                                onClick = onClickManga,
-                                onLongClick = onLongClickManga,
-                            )
-                        }
-                    }
-                }
-            }
-        }
-        boundViews.add(view)
-    }
-
-    /**
-     * Recycles a view.
-     *
-     * @param view the view to recycle.
-     * @param position the position in the adapter.
-     */
-    override fun recycleView(view: View, position: Int) {
-        boundViews.remove(view)
-    }
-
-    /**
-     * Returns the number of categories.
-     *
-     * @return the number of categories or 0 if the list is null.
-     */
-    override fun getCount(): Int {
-        return categories.size
-    }
-
-    /**
-     * Returns the title to display for a category.
-     *
-     * @param position the position of the element.
-     * @return the title to display.
-     */
-    override fun getPageTitle(position: Int): CharSequence {
-        return if (!preferences.categoryNumberOfItems().get()) {
-            categories[position].name
-        } else {
-            categories[position].let { "${it.name} (${itemsPerCategory[position]})" }
-        }
-    }
-
-    override fun getViewType(position: Int): Int = -1
-}

+ 50 - 373
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt

@@ -1,240 +1,119 @@
 package eu.kanade.tachiyomi.ui.library
 
-import android.content.res.Configuration
 import android.os.Bundle
-import android.view.LayoutInflater
 import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
 import android.view.View
-import androidx.appcompat.view.ActionMode
-import androidx.core.view.isVisible
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
 import com.bluelinelabs.conductor.ControllerChangeHandler
 import com.bluelinelabs.conductor.ControllerChangeType
-import com.fredporciuncula.flow.preferences.Preference
-import com.google.android.material.tabs.TabLayout
-import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.domain.category.model.Category
 import eu.kanade.domain.category.model.toDbCategory
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.model.toDbManga
+import eu.kanade.presentation.library.LibraryScreen
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.toDomainManga
 import eu.kanade.tachiyomi.data.library.LibraryUpdateService
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
+import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
-import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
-import eu.kanade.tachiyomi.ui.base.controller.TabbedController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.launchUI
-import eu.kanade.tachiyomi.util.preference.asHotFlow
-import eu.kanade.tachiyomi.util.system.getResourceColor
-import eu.kanade.tachiyomi.util.system.openInBrowser
 import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.widget.ActionModeWithToolbar
-import eu.kanade.tachiyomi.widget.EmptyView
 import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
 import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.android.view.clicks
-import reactivecircus.flowbinding.viewpager.pageSelections
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-import java.util.concurrent.TimeUnit
 
 class LibraryController(
     bundle: Bundle? = null,
-    private val preferences: PreferencesHelper = Injekt.get(),
-) : SearchableNucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
+) : FullComposeController<LibraryPresenter>(bundle),
     RootController,
-    TabbedController,
-    ActionModeWithToolbar.Callback,
     ChangeMangaCategoriesDialog.Listener,
     DeleteLibraryMangasDialog.Listener {
 
-    /**
-     * Position of the active category.
-     */
-    private var activeCategory: Int = preferences.lastUsedCategory().get()
-
-    /**
-     * Action mode for selections.
-     */
-    private var actionMode: ActionModeWithToolbar? = null
-
-    private var mangaMap: LibraryMap = emptyMap()
-
-    private var adapter: LibraryAdapter? = null
-
     /**
      * Sheet containing filter/sort/display items.
      */
     private var settingsSheet: LibrarySettingsSheet? = null
 
-    private var tabsVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
-
-    private var mangaCountVisibilityRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
-
-    private var tabsVisibilitySubscription: Subscription? = null
-
-    private var mangaCountVisibilitySubscription: Subscription? = null
-
     init {
-        setHasOptionsMenu(true)
         retainViewMode = RetainViewMode.RETAIN_DETACH
     }
 
-    private var currentTitle: String? = null
-        set(value) {
-            if (field != value) {
-                field = value
-                setTitle()
-            }
-        }
+    override fun createPresenter(): LibraryPresenter = LibraryPresenter()
 
-    override fun getTitle(): String? {
-        return currentTitle ?: resources?.getString(R.string.label_library)
-    }
-
-    private fun updateTitle() {
-        val showCategoryTabs = preferences.categoryTabs().get()
-        val currentCategory = adapter?.categories?.getOrNull(binding.libraryPager.currentItem)
-
-        var title = if (showCategoryTabs) {
-            resources?.getString(R.string.label_library)
-        } else {
-            currentCategory?.name
-        }
-
-        if (preferences.categoryNumberOfItems().get()) {
-            if (!showCategoryTabs || adapter?.categories?.size == 1) {
-                title += " (${mangaMap[currentCategory?.id]?.size ?: 0})"
-            }
+    @Composable
+    override fun ComposeContent() {
+        val context = LocalContext.current
+        LibraryScreen(
+            presenter = presenter,
+            onMangaClicked = ::openManga,
+            onGlobalSearchClicked = {
+                router.pushController(GlobalSearchController(presenter.query))
+            },
+            onChangeCategoryClicked = ::showMangaCategoriesDialog,
+            onMarkAsReadClicked = { markReadStatus(true) },
+            onMarkAsUnreadClicked = { markReadStatus(false) },
+            onDownloadClicked = ::downloadUnreadChapters,
+            onDeleteClicked = ::showDeleteMangaDialog,
+            onClickFilter = ::showSettingsSheet,
+            onClickRefresh = {
+                if (LibraryUpdateService.start(context)) {
+                    context.toast(R.string.updating_library)
+                }
+            },
+            onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) },
+            onClickSelectAll = { presenter.selectAll(presenter.activeCategory) },
+            onClickUnselectAll = ::clearSelection,
+        )
+        LaunchedEffect(presenter.selectionMode) {
+            val activity = (activity as? MainActivity) ?: return@LaunchedEffect
+            activity.showBottomNav(presenter.selectionMode.not())
         }
-
-        currentTitle = title
     }
 
-    override fun createPresenter(): LibraryPresenter {
-        return LibraryPresenter()
+    override fun handleBack(): Boolean {
+        if (presenter.selection.isNotEmpty()) {
+            presenter.clearSelection()
+            return true
+        }
+        return false
     }
 
-    override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater)
-
     override fun onViewCreated(view: View) {
         super.onViewCreated(view)
 
-        adapter = LibraryAdapter(
-            presenter = presenter,
-            onClickManga = {
-                openManga(it.id!!)
-            },
-        )
-
-        getColumnsPreferenceForCurrentOrientation()
-            .asHotFlow { presenter.columns = it }
-            .launchIn(viewScope)
-
-        binding.libraryPager.adapter = adapter
-        binding.libraryPager.pageSelections()
-            .drop(1)
-            .onEach {
-                preferences.lastUsedCategory().set(it)
-                activeCategory = it
-                updateTitle()
-            }
-            .launchIn(viewScope)
-
-        if (adapter!!.categories.isNotEmpty()) {
-            createActionModeIfNeeded()
-        }
-
         settingsSheet = LibrarySettingsSheet(router) { group ->
             when (group) {
                 is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged()
                 is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged()
-                is LibrarySettingsSheet.Display.DisplayGroup -> {
-                    val delay = if (preferences.categorizedDisplaySettings().get()) 125L else 0L
-
-                    Observable.timer(delay, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
-                        .subscribe {
-                            reattachAdapter()
-                        }
-                }
+                is LibrarySettingsSheet.Display.DisplayGroup -> {}
                 is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged()
-                is LibrarySettingsSheet.Display.TabsGroup -> onTabsSettingsChanged()
+                is LibrarySettingsSheet.Display.TabsGroup -> {} // onTabsSettingsChanged()
             }
         }
-
-        binding.btnGlobalSearch.clicks()
-            .onEach {
-                router.pushController(GlobalSearchController(presenter.query))
-            }
-            .launchIn(viewScope)
-    }
-
-    private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
-        return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
-            preferences.portraitColumns()
-        } else {
-            preferences.landscapeColumns()
-        }
     }
 
     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
         super.onChangeStarted(handler, type)
         if (type.isEnter) {
-            (activity as? MainActivity)?.binding?.tabs?.setupWithViewPager(binding.libraryPager)
             presenter.subscribeLibrary()
         }
     }
 
     override fun onDestroyView(view: View) {
-        destroyActionModeIfNeeded()
-        adapter = null
         settingsSheet?.sheetScope?.cancel()
         settingsSheet = null
-        tabsVisibilitySubscription?.unsubscribe()
-        tabsVisibilitySubscription = null
         super.onDestroyView(view)
     }
 
-    override fun configureTabs(tabs: TabLayout): Boolean {
-        with(tabs) {
-            isVisible = false
-            tabGravity = TabLayout.GRAVITY_START
-            tabMode = TabLayout.MODE_SCROLLABLE
-        }
-        tabsVisibilitySubscription?.unsubscribe()
-        tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible ->
-            tabs.isVisible = visible
-        }
-        mangaCountVisibilitySubscription?.unsubscribe()
-        mangaCountVisibilitySubscription = mangaCountVisibilityRelay.subscribe {
-            adapter?.notifyDataSetChanged()
-        }
-
-        return false
-    }
-
-    override fun cleanupTabs(tabs: TabLayout) {
-        tabsVisibilitySubscription?.unsubscribe()
-        tabsVisibilitySubscription = null
-    }
-
     fun showSettingsSheet() {
-        if (adapter?.categories?.isNotEmpty() == true) {
-            adapter?.categories?.get(binding.libraryPager.currentItem)?.let { category ->
+        if (presenter.categories.isNotEmpty()) {
+            presenter.categories[presenter.activeCategory].let { category ->
                 settingsSheet?.show(category.toDbCategory())
             }
         } else {
@@ -242,61 +121,6 @@ class LibraryController(
         }
     }
 
-    fun onNextLibraryUpdate(categories: List<Category>, mangaMap: LibraryMap) {
-        val view = view ?: return
-        val adapter = adapter ?: return
-
-        // Show empty view if needed
-        if (mangaMap.isNotEmpty()) {
-            binding.emptyView.hide()
-        } else {
-            binding.emptyView.show(
-                R.string.information_empty_library,
-                listOf(
-                    EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) {
-                        activity?.openInBrowser("https://tachiyomi.org/help/guides/getting-started")
-                    },
-                ),
-            )
-            (activity as? MainActivity)?.ready = true
-        }
-
-        // Get the current active category.
-        val activeCat = if (adapter.categories.isNotEmpty()) {
-            binding.libraryPager.currentItem
-        } else {
-            activeCategory
-        }
-
-        // Set the categories
-        adapter.updateCategories(categories.map { it to (mangaMap[it.id]?.size ?: 0) })
-
-        // Restore active category.
-        binding.libraryPager.setCurrentItem(activeCat, false)
-
-        // Trigger display of tabs
-        onTabsSettingsChanged(firstLaunch = true)
-
-        // Delay the scroll position to allow the view to be properly measured.
-        view.post {
-            if (isAttached) {
-                (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true)
-            }
-        }
-
-        presenter.loadedManga.clear()
-        mangaMap.forEach {
-            presenter.loadedManga[it.key] = it.value
-        }
-        presenter.loadedMangaFlow.value = presenter.loadedManga
-
-        // Send the manga map to child fragments after the adapter is updated.
-        this.mangaMap = mangaMap
-
-        // Finally update the title
-        updateTitle()
-    }
-
     private fun onFilterChanged() {
         presenter.requestFilterUpdate()
         activity?.invalidateOptionsMenu()
@@ -306,146 +130,17 @@ class LibraryController(
         presenter.requestBadgesUpdate()
     }
 
-    private fun onTabsSettingsChanged(firstLaunch: Boolean = false) {
-        if (!firstLaunch) {
-            mangaCountVisibilityRelay.call(preferences.categoryNumberOfItems().get())
-        }
-        tabsVisibilityRelay.call(preferences.categoryTabs().get() && (adapter?.categories?.size ?: 0) > 1)
-        updateTitle()
-    }
-
     private fun onSortChanged() {
         presenter.requestSortUpdate()
     }
 
-    /**
-     * Reattaches the adapter to the view pager to recreate fragments
-     */
-    private fun reattachAdapter() {
-        val adapter = adapter ?: return
-
-        val position = binding.libraryPager.currentItem
-
-        adapter.recycle = false
-        binding.libraryPager.adapter = adapter
-        binding.libraryPager.currentItem = position
-        adapter.recycle = true
-    }
-
-    fun createActionModeIfNeeded() {
-        val activity = activity
-        if (actionMode == null && activity is MainActivity) {
-            actionMode = activity.startActionModeAndToolbar(this)
-            activity.showBottomNav(false)
-        }
-    }
-
-    private fun destroyActionModeIfNeeded() {
-        actionMode?.finish()
-    }
-
-    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search)
-        // Mutate the filter icon because it needs to be tinted and the resource is shared.
-        menu.findItem(R.id.action_filter).icon?.mutate()
-    }
-
     fun search(query: String) {
-        presenter.query = query
-    }
-
-    private fun performSearch() {
-        if (presenter.query.isNotEmpty()) {
-            binding.btnGlobalSearch.isVisible = true
-            binding.btnGlobalSearch.text =
-                resources?.getString(R.string.action_global_search_query, presenter.query)
-        } else {
-            binding.btnGlobalSearch.isVisible = false
-        }
+        presenter.searchQuery = query
     }
 
     override fun onPrepareOptionsMenu(menu: Menu) {
         val settingsSheet = settingsSheet ?: return
-
-        val filterItem = menu.findItem(R.id.action_filter)
-
-        // Tint icon if there's a filter active
-        if (settingsSheet.filters.hasActiveFilters()) {
-            val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
-            filterItem.icon?.setTint(filterColor)
-        }
-    }
-
-    override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.action_search -> expandActionViewFromInteraction = true
-            R.id.action_filter -> showSettingsSheet()
-            R.id.action_update_library -> {
-                activity?.let {
-                    if (LibraryUpdateService.start(it)) {
-                        it.toast(R.string.updating_library)
-                    }
-                }
-            }
-        }
-
-        return super.onOptionsItemSelected(item)
-    }
-
-    /**
-     * Invalidates the action mode, forcing it to refresh its content.
-     */
-    fun invalidateActionMode() {
-        actionMode?.invalidate()
-    }
-
-    override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
-        mode.menuInflater.inflate(R.menu.generic_selection, menu)
-        return true
-    }
-
-    override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) {
-        menuInflater.inflate(R.menu.library_selection, menu)
-    }
-
-    override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
-        val count = presenter.selection.size
-        if (count == 0) {
-            // Destroy action mode if there are no items selected.
-            destroyActionModeIfNeeded()
-        } else {
-            mode.title = count.toString()
-        }
-        return true
-    }
-
-    override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
-        if (presenter.hasSelection().not()) return
-        toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible =
-            presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } }
-    }
-
-    override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.action_move_to_category -> showMangaCategoriesDialog()
-            R.id.action_download_unread -> downloadUnreadChapters()
-            R.id.action_mark_as_read -> markReadStatus(true)
-            R.id.action_mark_as_unread -> markReadStatus(false)
-            R.id.action_delete -> showDeleteMangaDialog()
-            R.id.action_select_all -> selectAllCategoryManga()
-            R.id.action_select_inverse -> selectInverseCategoryManga()
-            else -> return false
-        }
-        return true
-    }
-
-    override fun onDestroyActionMode(mode: ActionMode) {
-        // Clear all the manga selections and notify child views.
-        presenter.clearSelection()
-
-        (activity as? MainActivity)?.showBottomNav(true)
-
-        actionMode = null
+        presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters()
     }
 
     private fun openManga(mangaId: Long) {
@@ -461,7 +156,6 @@ class LibraryController(
      */
     fun clearSelection() {
         presenter.clearSelection()
-        invalidateActionMode()
     }
 
     /**
@@ -496,13 +190,13 @@ class LibraryController(
     private fun downloadUnreadChapters() {
         val mangas = presenter.selection.toList()
         presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() })
-        destroyActionModeIfNeeded()
+        presenter.clearSelection()
     }
 
     private fun markReadStatus(read: Boolean) {
         val mangas = presenter.selection.toList()
         presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read)
-        destroyActionModeIfNeeded()
+        presenter.clearSelection()
     }
 
     private fun showDeleteMangaDialog() {
@@ -512,28 +206,11 @@ class LibraryController(
 
     override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
         presenter.setMangaCategories(mangas, addCategories, removeCategories)
-        destroyActionModeIfNeeded()
+        presenter.clearSelection()
     }
 
     override fun deleteMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
         presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters)
-        destroyActionModeIfNeeded()
-    }
-
-    private fun selectAllCategoryManga() {
-        presenter.selectAll(binding.libraryPager.currentItem)
-    }
-
-    private fun selectInverseCategoryManga() {
-        presenter.invertSelection(binding.libraryPager.currentItem)
-    }
-
-    override fun onSearchViewQueryTextChange(newText: String?) {
-        // Ignore events if this controller isn't at the top to avoid query being reset
-        if (router.backstack.lastOrNull()?.controller == this) {
-            presenter.query = newText ?: ""
-            presenter.searchQuery = newText ?: ""
-            performSearch()
-        }
+        presenter.clearSelection()
     }
 }

+ 111 - 63
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt

@@ -4,13 +4,15 @@ import android.os.Bundle
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.util.fastAny
 import com.jakewharton.rxrelay.BehaviorRelay
+import eu.kanade.core.prefs.PreferenceMutableState
+import eu.kanade.core.util.asFlow
 import eu.kanade.core.util.asObservable
 import eu.kanade.data.DatabaseHandler
 import eu.kanade.domain.category.interactor.GetCategories
@@ -25,6 +27,10 @@ import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.model.MangaUpdate
 import eu.kanade.domain.manga.model.isLocal
 import eu.kanade.domain.track.interactor.GetTracks
+import eu.kanade.presentation.library.LibraryState
+import eu.kanade.presentation.library.LibraryStateImpl
+import eu.kanade.presentation.library.components.LibraryToolbarTitle
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.models.LibraryManga
 import eu.kanade.tachiyomi.data.database.models.toDomainManga
@@ -39,14 +45,16 @@ import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
 import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
 import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
 import eu.kanade.tachiyomi.util.lang.combineLatest
-import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.removeCovers
 import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
-import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.runBlocking
 import rx.Observable
-import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
 import uy.kohesive.injekt.Injekt
@@ -70,6 +78,7 @@ typealias LibraryMap = Map<Long, List<LibraryItem>>
  * Presenter of [LibraryController].
  */
 class LibraryPresenter(
+    private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
     private val handler: DatabaseHandler = Injekt.get(),
     private val getLibraryManga: GetLibraryManga = Injekt.get(),
     private val getTracks: GetTracks = Injekt.get(),
@@ -83,30 +92,26 @@ class LibraryPresenter(
     private val sourceManager: SourceManager = Injekt.get(),
     private val downloadManager: DownloadManager = Injekt.get(),
     private val trackManager: TrackManager = Injekt.get(),
-) : BasePresenter<LibraryController>() {
+) : BasePresenter<LibraryController>(), LibraryState by state {
 
     private val context = preferences.context
 
-    /**
-     * Categories of the library.
-     */
-    var categories: List<Category> = mutableStateListOf()
+    var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>())
         private set
 
-    var loadedManga = mutableStateMapOf<Long, List<LibraryItem>>()
-        private set
+    val isPerCategory by preferences.categorizedDisplaySettings().asState()
 
-    val loadedMangaFlow = MutableStateFlow(loadedManga)
+    var currentDisplayMode by preferences.libraryDisplayMode().asState()
 
-    var searchQuery by mutableStateOf(query)
+    val tabVisibility by preferences.categoryTabs().asState()
 
-    val selection: MutableList<LibraryManga> = mutableStateListOf()
+    val mangaCountVisibility by preferences.categoryNumberOfItems().asState()
 
-    val isPerCategory by preferences.categorizedDisplaySettings().asState()
+    var activeCategory: Int by preferences.lastUsedCategory().asState()
 
-    var columns by mutableStateOf(0)
+    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
 
-    var currentDisplayMode by preferences.libraryDisplayMode().asState()
+    val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
 
     /**
      * Relay used to apply the UI filters to the last emission of the library.
@@ -123,7 +128,7 @@ class LibraryPresenter(
      */
     private val sortTriggerRelay = BehaviorRelay.create(Unit)
 
-    private var librarySubscription: Subscription? = null
+    private var librarySubscription: Job? = null
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -135,22 +140,31 @@ class LibraryPresenter(
      * Subscribes to library if needed.
      */
     fun subscribeLibrary() {
-        // TODO: Move this to a coroutine world
-        if (librarySubscription.isNullOrUnsubscribed()) {
-            librarySubscription = getLibraryObservable()
-                .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
-                    lib.apply { setBadges(mangaMap) }
-                }
-                .combineLatest(getFilterObservable()) { lib, tracks ->
-                    lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks))
-                }
-                .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
-                    lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
-                }
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribeLatestCache({ view, (categories, mangaMap) ->
-                    view.onNextLibraryUpdate(categories, mangaMap)
-                },)
+        /**
+         * TODO: Move this to a coroutine world
+         * - Move filter and sort to getMangaForCategory and only filter and sort the current display category instead of whole library as some has 5000+ items in the library
+         * - Create new db view and new query to just fetch the current category save as needed to instance variable
+         * - Fetch badges to maps and retrive as needed instead of fetching all of them at once
+         */
+        if (librarySubscription == null || librarySubscription!!.isCancelled) {
+            librarySubscription = presenterScope.launchIO {
+                getLibraryObservable()
+                    .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
+                        lib.apply { setBadges(mangaMap) }
+                    }
+                    .combineLatest(getFilterObservable()) { lib, tracks ->
+                        lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks))
+                    }
+                    .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ ->
+                        lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap))
+                    }
+                    .observeOn(AndroidSchedulers.mainThread())
+                    .asFlow()
+                    .collectLatest {
+                        state.isLoading = false
+                        loadedManga = it.mangaMap
+                    }
+            }
         }
     }
 
@@ -397,7 +411,7 @@ class LibraryPresenter(
      * @return an observable of the categories and its manga.
      */
     private fun getLibraryObservable(): Observable<Library> {
-        return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga ->
+        return combine(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga ->
             val categories = if (libraryManga.containsKey(0)) {
                 arrayListOf(Category.default(context)) + dbCategories
             } else {
@@ -411,9 +425,9 @@ class LibraryPresenter(
                 }
             }
 
-            this.categories = categories
+            state.categories = categories
             Library(categories, libraryManga)
-        }
+        }.asObservable()
     }
 
     /**
@@ -421,8 +435,8 @@ class LibraryPresenter(
      *
      * @return an observable of the categories.
      */
-    private fun getCategoriesObservable(): Observable<List<Category>> {
-        return getCategories.subscribe().asObservable()
+    private fun getCategoriesObservable(): Flow<List<Category>> {
+        return getCategories.subscribe()
     }
 
     /**
@@ -431,8 +445,8 @@ class LibraryPresenter(
      * @return an observable containing a map with the category id as key and a list of manga as the
      * value.
      */
-    private fun getLibraryMangasObservable(): Observable<LibraryMap> {
-        return getLibraryManga.subscribe().asObservable()
+    private fun getLibraryMangasObservable(): Flow<LibraryMap> {
+        return getLibraryManga.subscribe()
             .map { list ->
                 list.map { libraryManga ->
                     // Display mode based on user preference: take it from global library setting or category
@@ -447,7 +461,8 @@ class LibraryPresenter(
      * @return an observable of tracked manga.
      */
     private fun getFilterObservable(): Observable<Map<Long, Map<Long, Boolean>>> {
-        return getTracksObservable().combineLatest(filterTriggerRelay.observeOn(Schedulers.io())) { tracks, _ -> tracks }
+        return filterTriggerRelay.observeOn(Schedulers.io())
+            .combineLatest(getTracksObservable()) { _, tracks -> tracks }
     }
 
     /**
@@ -458,7 +473,7 @@ class LibraryPresenter(
     private fun getTracksObservable(): Observable<Map<Long, Map<Long, Boolean>>> {
         // TODO: Move this to domain/data layer
         return getTracks.subscribe()
-            .asObservable().map { tracks ->
+            .map { tracks ->
                 tracks
                     .groupBy { it.mangaId }
                     .mapValues { tracksForMangaId ->
@@ -468,6 +483,7 @@ class LibraryPresenter(
                         }
                     }
             }
+            .asObservable()
             .observeOn(Schedulers.io())
     }
 
@@ -497,7 +513,7 @@ class LibraryPresenter(
      */
     fun onOpenManga() {
         // Avoid further db updates for the library when it's not needed
-        librarySubscription?.let { remove(it) }
+        librarySubscription?.cancel()
     }
 
     /**
@@ -610,14 +626,50 @@ class LibraryPresenter(
     }
 
     @Composable
-    fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State<List<LibraryItem>> {
+    fun getMangaCountForCategory(categoryId: Long): androidx.compose.runtime.State<Int?> {
+        return produceState<Int?>(initialValue = null, loadedManga) {
+            value = loadedManga[categoryId]?.size
+        }
+    }
+
+    fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
+        return (if (isLandscape) preferences.landscapeColumns() else preferences.portraitColumns()).asState()
+    }
+
+    // TODO: This is good but should we separate title from count or get categories with count from db
+    @Composable
+    fun getToolbarTitle(): androidx.compose.runtime.State<LibraryToolbarTitle> {
+        val category = categories.getOrNull(activeCategory)
+
+        val defaultTitle = stringResource(id = R.string.label_library)
+        val default = remember { LibraryToolbarTitle(defaultTitle) }
+
+        return produceState(initialValue = default, category, mangaCountVisibility, tabVisibility) {
+            val title = if (tabVisibility.not()) category?.name ?: defaultTitle else defaultTitle
+
+            value = when {
+                category == null -> default
+                (tabVisibility.not() && mangaCountVisibility.not()) -> LibraryToolbarTitle(title)
+                tabVisibility.not() && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size)
+                (tabVisibility && categories.size > 1) && mangaCountVisibility -> LibraryToolbarTitle(title)
+                tabVisibility && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size)
+                else -> default
+            }
+        }
+    }
+
+    @Composable
+    fun getMangaForCategory(page: Int): androidx.compose.runtime.State<List<LibraryItem>> {
+        val categoryId = remember(categories) {
+            categories.getOrNull(page)?.id ?: -1
+        }
         val unfiltered = loadedManga[categoryId] ?: emptyList()
 
         return derivedStateOf {
             val query = searchQuery
-            if (query.isNotBlank()) {
+            if (query.isNullOrBlank().not()) {
                 unfiltered.filter {
-                    it.filter(query)
+                    it.filter(query!!)
                 }
             } else {
                 unfiltered
@@ -626,9 +678,9 @@ class LibraryPresenter(
     }
 
     @Composable
-    fun getDisplayMode(index: Int): DisplayModeSetting {
+    fun getDisplayMode(index: Int): androidx.compose.runtime.State<DisplayModeSetting> {
         val category = categories[index]
-        return remember {
+        return derivedStateOf {
             if (isPerCategory.not() || category.id == 0L) {
                 currentDisplayMode
             } else {
@@ -642,34 +694,30 @@ class LibraryPresenter(
     }
 
     fun clearSelection() {
-        selection.clear()
+        state.selection = emptyList()
     }
 
     fun toggleSelection(manga: LibraryManga) {
+        val mutableList = state.selection.toMutableList()
         if (selection.fastAny { it.id == manga.id }) {
-            selection.remove(manga)
+            mutableList.remove(manga)
         } else {
-            selection.add(manga)
+            mutableList.add(manga)
         }
-        view?.invalidateActionMode()
-        view?.createActionModeIfNeeded()
+        state.selection = mutableList
     }
 
     fun selectAll(index: Int) {
         val category = categories[index]
         val items = loadedManga[category.id] ?: emptyList()
-        selection.addAll(items.filterNot { it.manga in selection }.map { it.manga })
-        view?.createActionModeIfNeeded()
-        view?.invalidateActionMode()
+        state.selection = state.selection.toMutableList().apply {
+            addAll(items.filterNot { it.manga in selection }.map { it.manga })
+        }
     }
 
     fun invertSelection(index: Int) {
         val category = categories[index]
         val items = (loadedManga[category.id] ?: emptyList()).map { it.manga }
-        val invert = items.filterNot { it in selection }
-        selection.removeAll(items)
-        selection.addAll(invert)
-        view?.createActionModeIfNeeded()
-        view?.invalidateActionMode()
+        state.selection = items.filterNot { it in selection }
     }
 }

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -488,9 +488,13 @@ class MainActivity : BaseActivity() {
             return
         }
         val backstackSize = router.backstackSize
-        if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
+        val startScreen = router.getControllerWithTag("$startScreenId")
+        if (backstackSize == 1 && startScreen == null) {
             // Return to start screen
             moveToStartScreen()
+            setSelectedNavItem(startScreenId)
+        } else if (startScreen != null && router.handleBack()) {
+            // Clear selection for Library screen
         } else if (shouldHandleExitConfirmation()) {
             // Exit confirmation (resets after 2 seconds)
             lifecycleScope.launchUI { resetExitConfirmation() }

+ 0 - 36
app/src/main/res/layout/library_controller.xml

@@ -1,36 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:orientation="vertical">
-
-        <Button
-            android:id="@+id/btn_global_search"
-            style="?attr/borderlessButtonStyle"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_margin="8dp"
-            android:visibility="gone"
-            tools:text="Search"
-            tools:visibility="visible" />
-
-        <androidx.viewpager.widget.ViewPager
-            android:id="@+id/library_pager"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent" />
-
-    </LinearLayout>
-
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:visibility="gone" />
-
-</FrameLayout>

+ 3 - 1
gradle/compose.versions.toml

@@ -19,4 +19,6 @@ material-icons = { module = "androidx.compose.material:material-icons-extended",
 
 accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
 accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
-accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" }
+accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref="accompanist" }
+accompanist-pager-core = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist"}
+accompanist-pager-indicators = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist"}