[优化] 优化书架的性能与稳定性
This commit is contained in:
@@ -115,15 +115,18 @@ object BookCover {
|
|||||||
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
|
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRandomDefaultDrawable(seed: Any? = null): Drawable {
|
fun getRandomDefaultPath(seed: Any? = null): String? {
|
||||||
val isNightTheme = AppConfig.isNightTheme
|
val isNightTheme = AppConfig.isNightTheme
|
||||||
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
|
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
|
||||||
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
|
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
|
||||||
if (paths.isNullOrEmpty()) {
|
if (paths.isNullOrEmpty()) return null
|
||||||
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
|
||||||
}
|
|
||||||
val random = if (seed != null) Random(seed.hashCode()) else Random
|
val random = if (seed != null) Random(seed.hashCode()) else Random
|
||||||
val randomPath = paths[random.nextInt(paths.size)]
|
return paths[random.nextInt(paths.size)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRandomDefaultDrawable(seed: Any? = null): Drawable {
|
||||||
|
val randomPath = getRandomDefaultPath(seed)
|
||||||
|
?: return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
|
||||||
return kotlin.runCatching {
|
return kotlin.runCatching {
|
||||||
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
|
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
|
||||||
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
|
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.legado.app.ui.main.bookshelf
|
package io.legado.app.ui.main.bookshelf
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
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.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -48,6 +49,7 @@ fun BookshelfConfigSheet(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.animateContentSize()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(bottom = 32.dp),
|
.padding(bottom = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package io.legado.app.ui.main.bookshelf
|
package io.legado.app.ui.main.bookshelf
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -32,6 +35,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -49,6 +54,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
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.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -56,6 +63,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
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 io.legado.app.R
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleEvent
|
||||||
import io.legado.app.data.entities.Book
|
import io.legado.app.data.entities.Book
|
||||||
import io.legado.app.ui.about.AppLogSheet
|
import io.legado.app.ui.about.AppLogSheet
|
||||||
import io.legado.app.ui.book.cache.CacheActivity
|
import io.legado.app.ui.book.cache.CacheActivity
|
||||||
@@ -64,7 +72,6 @@ import io.legado.app.ui.book.import.remote.RemoteBookActivity
|
|||||||
import io.legado.app.ui.book.manage.BookshelfManageActivity
|
import io.legado.app.ui.book.manage.BookshelfManageActivity
|
||||||
import io.legado.app.ui.book.search.SearchActivity
|
import io.legado.app.ui.book.search.SearchActivity
|
||||||
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
|
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
|
||||||
import io.legado.app.ui.file.HandleFileContract
|
|
||||||
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
|
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
|
||||||
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
|
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
|
||||||
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
||||||
@@ -75,6 +82,7 @@ import io.legado.app.utils.readText
|
|||||||
import io.legado.app.utils.startActivity
|
import io.legado.app.utils.startActivity
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@@ -94,38 +102,79 @@ fun BookshelfScreen(
|
|||||||
|
|
||||||
var showAddUrlDialog by remember { mutableStateOf(false) }
|
var showAddUrlDialog by remember { mutableStateOf(false) }
|
||||||
var showImportSheet by remember { mutableStateOf(false) }
|
var showImportSheet by remember { mutableStateOf(false) }
|
||||||
|
var showExportSheet by remember { mutableStateOf(false) }
|
||||||
var showConfigSheet by remember { mutableStateOf(false) }
|
var showConfigSheet by remember { mutableStateOf(false) }
|
||||||
var showGroupManageSheet by remember { mutableStateOf(false) }
|
var showGroupManageSheet by remember { mutableStateOf(false) }
|
||||||
var showLogSheet by remember { mutableStateOf(false) }
|
var showLogSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val importLauncher = rememberLauncherForActivityResult(HandleFileContract()) {
|
val clipboardManager = LocalClipboard.current
|
||||||
runCatching {
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
it.uri?.readText(context)?.let { text ->
|
|
||||||
val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
|
LaunchedEffect(Unit) {
|
||||||
viewModel.importBookshelf(text, groupId)
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is BaseRuleEvent.ShowSnackbar -> {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
actionLabel = event.actionLabel,
|
||||||
|
withDismissAction = true
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed && event.url != null) {
|
||||||
|
clipboardManager.setClipEntry(
|
||||||
|
ClipEntry(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
"url",
|
||||||
|
event.url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onFailure {
|
|
||||||
context.toastOnUi(it.localizedMessage ?: "ERROR")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val importLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
onResult = { uri ->
|
||||||
|
uri?.let {
|
||||||
|
runCatching {
|
||||||
|
val text = it.readText(context)
|
||||||
|
val groupId =
|
||||||
|
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
|
||||||
|
viewModel.importBookshelf(text, groupId)
|
||||||
|
}.onFailure {
|
||||||
|
context.toastOnUi(it.localizedMessage ?: "ERROR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||||
|
onResult = { uri ->
|
||||||
|
uri?.let { viewModel.exportToUri(it, uiState.items) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = uiState.selectedGroupIndex,
|
initialPage = uiState.selectedGroupIndex,
|
||||||
pageCount = { uiState.groups.size }
|
pageCount = { uiState.groups.size }
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
snapshotFlow { pagerState.currentPage }
|
||||||
if (uiState.groups.isNotEmpty() && page < uiState.groups.size) {
|
.distinctUntilChanged()
|
||||||
viewModel.changeGroup(uiState.groups[page].groupId)
|
.collect { page ->
|
||||||
|
if (uiState.groups.isNotEmpty() && page in uiState.groups.indices) {
|
||||||
|
val targetGroupId = uiState.groups[page].groupId
|
||||||
|
val currentGroupId =
|
||||||
|
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId
|
||||||
|
if (currentGroupId != targetGroupId) {
|
||||||
|
viewModel.changeGroup(targetGroupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.selectedGroupIndex) {
|
|
||||||
if (uiState.selectedGroupIndex != pagerState.currentPage && uiState.selectedGroupIndex < pagerState.pageCount) {
|
|
||||||
pagerState.scrollToPage(uiState.selectedGroupIndex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val bookGroupStyle = BookshelfConfig.bookGroupStyle
|
val bookGroupStyle = BookshelfConfig.bookGroupStyle
|
||||||
@@ -223,6 +272,7 @@ fun BookshelfScreen(
|
|||||||
RoundDropdownMenuItem(
|
RoundDropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.export_bookshelf)) },
|
text = { Text(stringResource(R.string.export_bookshelf)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
showExportSheet = true
|
||||||
dismiss()
|
dismiss()
|
||||||
},
|
},
|
||||||
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
|
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
|
||||||
@@ -241,12 +291,12 @@ fun BookshelfScreen(
|
|||||||
leadingIcon = { Icon(Icons.Default.History, null) }
|
leadingIcon = { Icon(Icons.Default.History, null) }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
bottomContent = if (bookGroupStyle == 0) {
|
bottomContent = if (bookGroupStyle == 0) {
|
||||||
{
|
{
|
||||||
if (uiState.groups.isNotEmpty()) {
|
if (uiState.groups.isNotEmpty()) {
|
||||||
val selectedTabIndex = remember(pagerState.currentPage, uiState.groups.size) {
|
val selectedTabIndex =
|
||||||
pagerState.currentPage.coerceIn(0, uiState.groups.size - 1)
|
pagerState.currentPage.coerceIn(0, uiState.groups.size - 1)
|
||||||
}
|
|
||||||
PrimaryScrollableTabRow(
|
PrimaryScrollableTabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
edgePadding = 0.dp,
|
edgePadding = 0.dp,
|
||||||
@@ -312,7 +362,7 @@ fun BookshelfScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
|
horizontalArrangement = Arrangement.spacedBy(if (bookshelfLayoutMode != 0) 8.dp else 0.dp),
|
||||||
showFastScroll = BookshelfConfig.showBookshelfFastScroller
|
showFastScroll = BookshelfConfig.showBookshelfFastScroller
|
||||||
) {
|
) {
|
||||||
items(uiState.groups) { group ->
|
itemsIndexed(uiState.groups, key = { _, it -> it.groupId }) { index, group ->
|
||||||
if (bookshelfLayoutMode == 0) {
|
if (bookshelfLayoutMode == 0) {
|
||||||
BookGroupItemList(
|
BookGroupItemList(
|
||||||
group = group,
|
group = group,
|
||||||
@@ -322,11 +372,8 @@ fun BookshelfScreen(
|
|||||||
titleCenter = BookshelfConfig.bookshelfTitleCenter,
|
titleCenter = BookshelfConfig.bookshelfTitleCenter,
|
||||||
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
|
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
|
||||||
onClick = {
|
onClick = {
|
||||||
val index = uiState.groups.indexOf(group)
|
scope.launch { pagerState.scrollToPage(index) }
|
||||||
if (index != -1) {
|
isInFolderRoot = false
|
||||||
scope.launch { pagerState.scrollToPage(index) }
|
|
||||||
isInFolderRoot = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLongClick = { showGroupManageSheet = true }
|
onLongClick = { showGroupManageSheet = true }
|
||||||
)
|
)
|
||||||
@@ -340,11 +387,8 @@ fun BookshelfScreen(
|
|||||||
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
|
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
|
||||||
coverShadow = BookshelfConfig.bookshelfCoverShadow,
|
coverShadow = BookshelfConfig.bookshelfCoverShadow,
|
||||||
onClick = {
|
onClick = {
|
||||||
val index = uiState.groups.indexOf(group)
|
scope.launch { pagerState.scrollToPage(index) }
|
||||||
if (index != -1) {
|
isInFolderRoot = false
|
||||||
scope.launch { pagerState.scrollToPage(index) }
|
|
||||||
isInFolderRoot = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLongClick = { showGroupManageSheet = true }
|
onLongClick = { showGroupManageSheet = true }
|
||||||
)
|
)
|
||||||
@@ -360,8 +404,10 @@ fun BookshelfScreen(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
val group = uiState.groups.getOrNull(pageIndex)
|
val group = uiState.groups.getOrNull(pageIndex)
|
||||||
if (group != null) {
|
if (group != null) {
|
||||||
val books by viewModel.getBooksFlow(group.groupId)
|
val booksFlow = remember(group.groupId) {
|
||||||
.collectAsState(emptyList())
|
viewModel.getBooksFlow(group.groupId)
|
||||||
|
}
|
||||||
|
val books by booksFlow.collectAsState(emptyList())
|
||||||
BookshelfPage(
|
BookshelfPage(
|
||||||
paddingValues = paddingValues,
|
paddingValues = paddingValues,
|
||||||
books = books,
|
books = books,
|
||||||
@@ -409,15 +455,28 @@ fun BookshelfScreen(
|
|||||||
onDismissRequest = { showImportSheet = false },
|
onDismissRequest = { showImportSheet = false },
|
||||||
title = stringResource(R.string.import_bookshelf),
|
title = stringResource(R.string.import_bookshelf),
|
||||||
onSelectSysFile = { types ->
|
onSelectSysFile = { types ->
|
||||||
importLauncher.launch {
|
importLauncher.launch(types)
|
||||||
mode = HandleFileContract.FILE
|
|
||||||
allowExtensions = types
|
|
||||||
}
|
|
||||||
showImportSheet = false
|
showImportSheet = false
|
||||||
},
|
},
|
||||||
onManualInput = {
|
onManualInput = {
|
||||||
showAddUrlDialog = true
|
showAddUrlDialog = true
|
||||||
showImportSheet = false
|
showImportSheet = false
|
||||||
|
},
|
||||||
|
allowExtensions = arrayOf("json", "txt")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExportSheet) {
|
||||||
|
FilePickerSheet(
|
||||||
|
onDismissRequest = { showExportSheet = false },
|
||||||
|
title = stringResource(R.string.export_bookshelf),
|
||||||
|
onSelectSysDir = {
|
||||||
|
showExportSheet = false
|
||||||
|
exportLauncher.launch("bookshelf.json")
|
||||||
|
},
|
||||||
|
onUpload = {
|
||||||
|
showExportSheet = false
|
||||||
|
viewModel.uploadBookshelf(uiState.items)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package io.legado.app.ui.main.bookshelf
|
package io.legado.app.ui.main.bookshelf
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.google.gson.stream.JsonWriter
|
import com.google.gson.stream.JsonWriter
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleEvent
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.constant.AppConst
|
import io.legado.app.constant.AppConst
|
||||||
import io.legado.app.constant.AppLog
|
import io.legado.app.constant.AppLog
|
||||||
@@ -16,6 +18,7 @@ import io.legado.app.data.entities.BookGroup
|
|||||||
import io.legado.app.data.entities.BookSource
|
import io.legado.app.data.entities.BookSource
|
||||||
import io.legado.app.data.entities.BookSourcePart
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
import io.legado.app.data.repository.BookGroupRepository
|
import io.legado.app.data.repository.BookGroupRepository
|
||||||
|
import io.legado.app.data.repository.UploadRepository
|
||||||
import io.legado.app.exception.NoStackTraceException
|
import io.legado.app.exception.NoStackTraceException
|
||||||
import io.legado.app.help.book.BookHelp
|
import io.legado.app.help.book.BookHelp
|
||||||
import io.legado.app.help.book.addType
|
import io.legado.app.help.book.addType
|
||||||
@@ -51,6 +54,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
@@ -69,11 +73,13 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
@@ -85,7 +91,8 @@ import kotlin.math.min
|
|||||||
|
|
||||||
class BookshelfViewModel(
|
class BookshelfViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val bookGroupRepository: BookGroupRepository
|
private val bookGroupRepository: BookGroupRepository,
|
||||||
|
private val uploadRepository: UploadRepository
|
||||||
) : BaseViewModel(application) {
|
) : BaseViewModel(application) {
|
||||||
var addBookJob: Coroutine<*>? = null
|
var addBookJob: Coroutine<*>? = null
|
||||||
|
|
||||||
@@ -107,6 +114,9 @@ class BookshelfViewModel(
|
|||||||
|
|
||||||
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
protected val _eventChannel = Channel<BaseRuleEvent>()
|
||||||
|
val events = _eventChannel.receiveAsFlow()
|
||||||
|
|
||||||
val groupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowShow()
|
val groupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowShow()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -512,6 +522,63 @@ class BookshelfViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun exportToUri(uri: Uri, books: List<Book>) {
|
||||||
|
execute {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
|
||||||
|
writer.setIndent(" ")
|
||||||
|
writer.beginArray()
|
||||||
|
books.forEach {
|
||||||
|
val bookMap = hashMapOf<String, String?>()
|
||||||
|
bookMap["name"] = it.name
|
||||||
|
bookMap["author"] = it.author
|
||||||
|
bookMap["intro"] = it.getDisplayIntro()
|
||||||
|
GSON.toJson(bookMap, bookMap::class.java, writer)
|
||||||
|
}
|
||||||
|
writer.endArray()
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
_eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出成功"))
|
||||||
|
}.onError {
|
||||||
|
_eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出失败\n${it.localizedMessage}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadBookshelf(books: List<Book>) {
|
||||||
|
execute {
|
||||||
|
val json = withContext(Dispatchers.Default) {
|
||||||
|
val list = books.map {
|
||||||
|
val bookMap = hashMapOf<String, String?>()
|
||||||
|
bookMap["name"] = it.name
|
||||||
|
bookMap["author"] = it.author
|
||||||
|
bookMap["intro"] = it.getDisplayIntro()
|
||||||
|
bookMap
|
||||||
|
}
|
||||||
|
GSON.toJson(list)
|
||||||
|
}
|
||||||
|
uploadRepository.upload(
|
||||||
|
fileName = "bookshelf.json",
|
||||||
|
file = json,
|
||||||
|
contentType = "application/json"
|
||||||
|
)
|
||||||
|
}.onSuccess { url ->
|
||||||
|
_eventChannel.trySend(
|
||||||
|
BaseRuleEvent.ShowSnackbar(
|
||||||
|
message = "上传成功: $url",
|
||||||
|
actionLabel = "复制链接",
|
||||||
|
url = url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.onError {
|
||||||
|
_eventChannel.trySend(
|
||||||
|
BaseRuleEvent.ShowSnackbar(
|
||||||
|
message = "上传失败: ${it.localizedMessage}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun exportBookshelf(books: List<Book>?, success: (file: File) -> Unit) {
|
fun exportBookshelf(books: List<Book>?, success: (file: File) -> Unit) {
|
||||||
execute {
|
execute {
|
||||||
books?.let {
|
books?.let {
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
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.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Book
|
||||||
import androidx.compose.material.icons.filled.Update
|
import androidx.compose.material.icons.filled.Update
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -59,12 +62,14 @@ fun BookCover(
|
|||||||
val useDefault = CoverConfig.useDefaultCover
|
val useDefault = CoverConfig.useDefaultCover
|
||||||
val finalPath = if (useDefault) null else path
|
val finalPath = if (useDefault) null else path
|
||||||
|
|
||||||
// 为每个 BookCover 组件实例基于其书名或路径生成一个随机封面
|
// 获取随机封面路径(字符串),而非直接获取 Drawable,避免主线程解码大图
|
||||||
// 这样书架上的每个默认封面都会不同,但对于同一本书又是固定的
|
val randomPath = remember(name, author, path, isNight) {
|
||||||
val randomDefault = remember(name, author, path) {
|
BookCover.getRandomDefaultPath(seed = name ?: author ?: path ?: "")
|
||||||
BookCover.getRandomDefaultDrawable(seed = name ?: author ?: path ?: "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否有自定义随机封面设置
|
||||||
|
val hasCustomDefault = !randomPath.isNullOrBlank()
|
||||||
|
|
||||||
var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) }
|
var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -76,25 +81,26 @@ fun BookCover(
|
|||||||
} else Modifier
|
} else Modifier
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
/*
|
|
||||||
.then(
|
|
||||||
if (CoverConfig.coverShowStroke) {
|
|
||||||
Modifier.border(
|
|
||||||
0.5.dp,
|
|
||||||
MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
RoundedCornerShape(4.dp)
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
) {
|
) {
|
||||||
// 1. 封面底图
|
// 如果没有自定义随机封面,显示书本图标作为底图
|
||||||
|
if (!hasCustomDefault) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Book,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 封面图层
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ImageRequest.Builder(context)
|
model = ImageRequest.Builder(context)
|
||||||
.data(finalPath ?: randomDefault)
|
.data(finalPath ?: randomPath)
|
||||||
.placeholder(randomDefault)
|
// 限制解码分辨率
|
||||||
.error(randomDefault)
|
.size(400, 560)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.setParameter("sourceOrigin", sourceOrigin)
|
.setParameter("sourceOrigin", sourceOrigin)
|
||||||
.setParameter("loadOnlyWifi", CoverConfig.loadCoverOnlyWifi)
|
.setParameter("loadOnlyWifi", CoverConfig.loadCoverOnlyWifi)
|
||||||
@@ -115,7 +121,7 @@ fun BookCover(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. 文字叠加层(当没有在线封面或者加载失败时显示)
|
// 2. 文字叠加层:当没有书籍自身封面(或加载失败)时,在随机底图上绘制书名/作者
|
||||||
if (!isOnlineCoverLoaded) {
|
if (!isOnlineCoverLoaded) {
|
||||||
CoverTextOverlay(
|
CoverTextOverlay(
|
||||||
name = name,
|
name = name,
|
||||||
@@ -152,7 +158,6 @@ private fun CoverTextOverlay(
|
|||||||
val showAuthor =
|
val showAuthor =
|
||||||
(if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
|
(if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
|
||||||
|
|
||||||
// 如果都不显示,直接返回
|
|
||||||
if (!showName && !showAuthor) return
|
if (!showName && !showAuthor) return
|
||||||
|
|
||||||
val secondaryColor = MaterialTheme.colorScheme.secondary.toArgb()
|
val secondaryColor = MaterialTheme.colorScheme.secondary.toArgb()
|
||||||
@@ -171,7 +176,6 @@ private fun CoverTextOverlay(
|
|||||||
drawIntoCanvas { canvas ->
|
drawIntoCanvas { canvas ->
|
||||||
val nativeCanvas = canvas.nativeCanvas
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
|
|
||||||
// 绘制书名
|
|
||||||
if (showName && !name.isNullOrBlank()) {
|
if (showName && !name.isNullOrBlank()) {
|
||||||
val paint = Paint().apply {
|
val paint = Paint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
@@ -200,9 +204,7 @@ private fun CoverTextOverlay(
|
|||||||
nativeCanvas.withSave {
|
nativeCanvas.withSave {
|
||||||
val textX = (viewWidth - maxWidth) / 2f
|
val textX = (viewWidth - maxWidth) / 2f
|
||||||
val textY = viewHeight * 0.08f
|
val textY = viewHeight * 0.08f
|
||||||
|
|
||||||
translate(textX, textY)
|
translate(textX, textY)
|
||||||
|
|
||||||
if (CoverConfig.coverShowStroke) {
|
if (CoverConfig.coverShowStroke) {
|
||||||
textPaint.style = Paint.Style.STROKE
|
textPaint.style = Paint.Style.STROKE
|
||||||
textPaint.strokeWidth = textPaint.textSize / 12
|
textPaint.strokeWidth = textPaint.textSize / 12
|
||||||
@@ -210,8 +212,6 @@ private fun CoverTextOverlay(
|
|||||||
textPaint.color = Color.White.toArgb()
|
textPaint.color = Color.White.toArgb()
|
||||||
textPaint.clearShadowLayer()
|
textPaint.clearShadowLayer()
|
||||||
layout.draw(this)
|
layout.draw(this)
|
||||||
|
|
||||||
// 还原
|
|
||||||
textPaint.style = Paint.Style.FILL
|
textPaint.style = Paint.Style.FILL
|
||||||
textPaint.color = originalColor
|
textPaint.color = originalColor
|
||||||
if (CoverConfig.coverShowShadow) {
|
if (CoverConfig.coverShowShadow) {
|
||||||
@@ -225,7 +225,6 @@ private fun CoverTextOverlay(
|
|||||||
var startY = viewHeight * 0.16f
|
var startY = viewHeight * 0.16f
|
||||||
val fm = paint.fontMetrics
|
val fm = paint.fontMetrics
|
||||||
val charHeight = fm.bottom - fm.top
|
val charHeight = fm.bottom - fm.top
|
||||||
|
|
||||||
name.forEach { char ->
|
name.forEach { char ->
|
||||||
if (CoverConfig.coverShowStroke) {
|
if (CoverConfig.coverShowStroke) {
|
||||||
val strokePaint = Paint(paint).apply {
|
val strokePaint = Paint(paint).apply {
|
||||||
@@ -246,7 +245,6 @@ private fun CoverTextOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绘制作者
|
|
||||||
if (showAuthor && !author.isNullOrBlank()) {
|
if (showAuthor && !author.isNullOrBlank()) {
|
||||||
val paint = Paint().apply {
|
val paint = Paint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
@@ -257,7 +255,6 @@ private fun CoverTextOverlay(
|
|||||||
setShadowLayer(4f, 1f, 1f, shadowColor)
|
setShadowLayer(4f, 1f, 1f, shadowColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHorizontal) {
|
if (isHorizontal) {
|
||||||
val authorText = TextUtils.ellipsize(
|
val authorText = TextUtils.ellipsize(
|
||||||
author,
|
author,
|
||||||
@@ -265,7 +262,6 @@ private fun CoverTextOverlay(
|
|||||||
viewWidth * 0.9f,
|
viewWidth * 0.9f,
|
||||||
TextUtils.TruncateAt.END
|
TextUtils.TruncateAt.END
|
||||||
)
|
)
|
||||||
|
|
||||||
if (CoverConfig.coverShowStroke) {
|
if (CoverConfig.coverShowStroke) {
|
||||||
val strokePaint = Paint(paint).apply {
|
val strokePaint = Paint(paint).apply {
|
||||||
color = Color.White.toArgb()
|
color = Color.White.toArgb()
|
||||||
@@ -292,7 +288,6 @@ private fun CoverTextOverlay(
|
|||||||
val charHeight = fm.bottom - fm.top
|
val charHeight = fm.bottom - fm.top
|
||||||
var startY = viewHeight * 0.16f - (author.length * charHeight)
|
var startY = viewHeight * 0.16f - (author.length * charHeight)
|
||||||
startY = startY.coerceAtLeast(viewHeight * 0.2f)
|
startY = startY.coerceAtLeast(viewHeight * 0.2f)
|
||||||
|
|
||||||
author.forEach { char ->
|
author.forEach { char ->
|
||||||
nativeCanvas.drawText(char.toString(), startX, startY, paint)
|
nativeCanvas.drawText(char.toString(), startX, startY, paint)
|
||||||
startY += charHeight
|
startY += charHeight
|
||||||
|
|||||||
Reference in New Issue
Block a user