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