diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/GalleryInfoBottomSheet.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/GalleryInfoBottomSheet.kt index 20a9bf42a..f33abd09a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/GalleryInfoBottomSheet.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/GalleryInfoBottomSheet.kt @@ -1,5 +1,6 @@ package com.hippo.ehviewer.ui +import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf( R.string.favorite_name to favoriteName, ) +context(Context, DestinationsNavigator) @Composable -fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) { +fun GalleryInfoBottomSheet(detail: GalleryDetail) { Column(modifier = Modifier.fillMaxWidth()) { - val context = LocalContext.current Text( text = stringResource(id = R.string.gallery_info), modifier = Modifier.align(Alignment.CenterHorizontally), @@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable { if (index == INDEX_PARENT) { if (content != null) { - navigator.navWithUrl(content) + navWithUrl(content) } } else { - context.addTextToClipboard(content, true) + addTextToClipboard(content, true) if (index == INDEX_URL) { // Save it to avoid detect the gallery Settings.clipboardTextHashCode = data[index].hashCode() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt new file mode 100644 index 000000000..3290598ea --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailContent.kt @@ -0,0 +1,801 @@ +package com.hippo.ehviewer.ui.screen + +import android.Manifest +import android.content.Context +import android.os.Environment +import android.text.TextUtils.TruncateAt.END +import androidx.annotation.StringRes +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ImageSearch +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SwapVerticalCircle +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.LocalPinnableContainer +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.core.text.parseAsHtml +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import androidx.window.core.layout.WindowWidthSizeClass +import arrow.core.partially1 +import arrow.fx.coroutines.parMap +import com.hippo.ehviewer.EhDB +import com.hippo.ehviewer.R +import com.hippo.ehviewer.Settings +import com.hippo.ehviewer.client.EhCookieStore +import com.hippo.ehviewer.client.EhEngine +import com.hippo.ehviewer.client.EhFilter.remember +import com.hippo.ehviewer.client.EhUrl +import com.hippo.ehviewer.client.EhUtils +import com.hippo.ehviewer.client.data.GalleryComment +import com.hippo.ehviewer.client.data.GalleryDetail +import com.hippo.ehviewer.client.data.GalleryInfo +import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED +import com.hippo.ehviewer.client.data.GalleryPreview +import com.hippo.ehviewer.client.data.GalleryTagGroup +import com.hippo.ehviewer.client.data.ListUrlBuilder +import com.hippo.ehviewer.client.data.TagNamespace +import com.hippo.ehviewer.client.data.asGalleryDetail +import com.hippo.ehviewer.client.data.findBaseInfo +import com.hippo.ehviewer.coil.justDownload +import com.hippo.ehviewer.collectAsState +import com.hippo.ehviewer.dao.DownloadInfo +import com.hippo.ehviewer.dao.Filter +import com.hippo.ehviewer.dao.FilterMode +import com.hippo.ehviewer.download.DownloadManager +import com.hippo.ehviewer.ktbuilder.imageRequest +import com.hippo.ehviewer.ktbuilder.launchIn +import com.hippo.ehviewer.ui.GalleryInfoBottomSheet +import com.hippo.ehviewer.ui.MainActivity +import com.hippo.ehviewer.ui.confirmRemoveDownload +import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination +import com.hippo.ehviewer.ui.getFavoriteIcon +import com.hippo.ehviewer.ui.jumpToReaderByPage +import com.hippo.ehviewer.ui.legacy.CoilImageGetter +import com.hippo.ehviewer.ui.main.EhPreviewItem +import com.hippo.ehviewer.ui.main.GalleryCommentCard +import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip +import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard +import com.hippo.ehviewer.ui.main.GalleryTags +import com.hippo.ehviewer.ui.main.TorrentList +import com.hippo.ehviewer.ui.modifyFavorites +import com.hippo.ehviewer.ui.navToReader +import com.hippo.ehviewer.ui.openBrowser +import com.hippo.ehviewer.ui.startDownload +import com.hippo.ehviewer.ui.tools.CrystalCard +import com.hippo.ehviewer.ui.tools.DialogState +import com.hippo.ehviewer.ui.tools.EmptyWindowInsets +import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid +import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton +import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton +import com.hippo.ehviewer.ui.tools.GalleryDetailRating +import com.hippo.ehviewer.ui.tools.GalleryRatingBar +import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass +import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope +import com.hippo.ehviewer.ui.tools.foldToLoadResult +import com.hippo.ehviewer.ui.tools.getClippedRefreshKey +import com.hippo.ehviewer.ui.tools.getLimit +import com.hippo.ehviewer.ui.tools.getOffset +import com.hippo.ehviewer.ui.tools.rememberInVM +import com.hippo.ehviewer.util.AppConfig +import com.hippo.ehviewer.util.FavouriteStatusRouter +import com.hippo.ehviewer.util.FileUtils +import com.hippo.ehviewer.util.addTextToClipboard +import com.hippo.ehviewer.util.bgWork +import com.hippo.ehviewer.util.flattenForEach +import com.hippo.ehviewer.util.isAtLeastQ +import com.hippo.ehviewer.util.requestPermission +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.logcat +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import moe.tarsin.coroutines.runSuspendCatching +import splitties.systemservices.downloadManager + +context(CoroutineScope, DestinationsNavigator, DialogState, MainActivity, SnackbarHostState, SharedTransitionScope, TransitionsVisibilityScope) +@Composable +fun GalleryDetailContent( + galleryInfo: GalleryInfo, + contentPadding: PaddingValues, + getDetailError: String, + onRetry: () -> Unit, + modifier: Modifier, +) { + val keylineMargin = dimensionResource(R.dimen.keyline_margin) + val galleryDetail = galleryInfo.asGalleryDetail() + val windowSizeClass = LocalWindowSizeClass.current + val thumbColumns by Settings.thumbColumns.collectAsState() + val readText = stringResource(R.string.read) + val startPage by rememberInVM { + EhDB.getReadProgressFlow(galleryInfo.gid) + }.collectAsState(0) + val readButtonText = if (startPage == 0) { + readText + } else { + stringResource(R.string.read_from, startPage + 1) + } + val downloadState by DownloadManager.collectDownloadState(galleryInfo.gid) + val downloadButtonText = when (downloadState) { + DownloadInfo.STATE_INVALID -> stringResource(R.string.download) + DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none) + DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait) + DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading) + DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded) + DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed) + else -> error("Invalid DownloadState!!!") + } + fun onReadButtonClick() { + if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) { + navToReader(galleryInfo.findBaseInfo(), startPage) + } + } + fun onCategoryChipClick() { + val category = galleryInfo.category + if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) { + return + } + navigate(ListUrlBuilder(category = category).asDst()) + } + fun onUploaderChipClick(galleryInfo: GalleryInfo) { + val uploader = galleryInfo.uploader + val disowned = uploader == "(Disowned)" + if (uploader.isNullOrEmpty() || disowned) { + return + } + navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_UPLOADER, mKeyword = uploader).asDst()) + } + + fun onGalleryInfoCardClick() { + galleryDetail ?: return + launch { + dialog { cont -> + ModalBottomSheet( + onDismissRequest = { cont.cancel() }, + contentWindowInsets = { EmptyWindowInsets }, + ) { + GalleryInfoBottomSheet(galleryDetail) + } + } + } + } + + val filterAdded = stringResource(R.string.filter_added) + fun showFilterUploaderDialog(galleryInfo: GalleryInfo) { + val uploader = galleryInfo.uploader + val disowned = uploader == "(Disowned)" + if (uploader.isNullOrEmpty() || disowned) { + return + } + launchIO { + awaitPermissionOrCancel { + Text(text = stringResource(R.string.filter_the_uploader, uploader)) + } + Filter(FilterMode.UPLOADER, uploader).remember() + showSnackbar(filterAdded) + } + } + fun onDownloadButtonClick() { + galleryDetail ?: return + if (DownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) { + launchUI { startDownload(implicit(), false, galleryDetail.galleryInfo) } + } else { + launch { confirmRemoveDownload(galleryDetail) } + } + } + + val previews = galleryDetail?.let { collectPreviewItems(it, thumbColumns) } + when (windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> FastScrollLazyVerticalGrid( + columns = GridCells.Fixed(thumbColumns), + contentPadding = contentPadding, + modifier = modifier.padding(horizontal = keylineMargin), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)), + ) { + item( + key = "header", + span = { GridItemSpan(maxCurrentLineSpan) }, + contentType = "header", + ) { + GalleryDetailHeaderCard( + info = galleryInfo, + onInfoCardClick = ::onGalleryInfoCardClick, + onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo), + onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo), + onCategoryChipClick = ::onCategoryChipClick, + modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin), + ) + } + item( + key = "body", + span = { GridItemSpan(maxCurrentLineSpan) }, + contentType = "body", + ) { + LocalPinnableContainer.current!!.run { remember { pin() } } + Column { + Row { + FilledTonalButton( + onClick = ::onDownloadButtonClick, + modifier = Modifier.padding(horizontal = 4.dp).weight(1F), + ) { + Text(text = downloadButtonText, maxLines = 1) + } + Button( + onClick = ::onReadButtonClick, + modifier = Modifier.padding(horizontal = 4.dp).weight(1F), + ) { + Text(text = readButtonText, maxLines = 1) + } + } + if (getDetailError.isNotBlank()) { + GalleryDetailErrorTip(error = getDetailError, onClick = onRetry) + } else if (galleryDetail != null) { + BelowHeader(galleryDetail) + } else { + Box( + modifier = Modifier.fillMaxSize().padding(keylineMargin), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + if (previews != null) { + galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) } + } + } + + WindowWidthSizeClass.EXPANDED -> FastScrollLazyVerticalGrid( + columns = GridCells.Fixed(thumbColumns), + contentPadding = contentPadding, + modifier = modifier.padding(horizontal = keylineMargin), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)), + ) { + item( + key = "header", + span = { GridItemSpan(maxCurrentLineSpan) }, + contentType = "header", + ) { + Row(modifier = Modifier.fillMaxWidth()) { + GalleryDetailHeaderCard( + info = galleryInfo, + onInfoCardClick = ::onGalleryInfoCardClick, + onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo), + onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo), + onCategoryChipClick = ::onCategoryChipClick, + modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin), + ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = modifier.height(16.dp)) + Button( + onClick = ::onReadButtonClick, + modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp), + ) { + Text(text = readButtonText, maxLines = 1) + } + Spacer(modifier = modifier.height(24.dp)) + FilledTonalButton( + onClick = ::onDownloadButtonClick, + modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp), + ) { + Text(text = downloadButtonText, maxLines = 1) + } + } + } + } + item( + key = "body", + span = { GridItemSpan(maxCurrentLineSpan) }, + contentType = "body", + ) { + LocalPinnableContainer.current!!.run { remember { pin() } } + Column { + if (getDetailError.isNotBlank()) { + GalleryDetailErrorTip(error = getDetailError, onClick = onRetry) + } else if (galleryDetail != null) { + BelowHeader(galleryDetail) + } else { + Box( + modifier = Modifier.fillMaxSize().padding(keylineMargin), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + if (previews != null) { + galleryPreview(previews) { navToReader(galleryDetail.galleryInfo, it) } + } + } + } +} + +context(Context, CoroutineScope, DestinationsNavigator, DialogState, SnackbarHostState) +@Composable +fun BelowHeader(galleryDetail: GalleryDetail) { + @Composable + fun EhIconButton( + icon: ImageVector, + text: String, + onClick: () -> Unit, + ) = Column(horizontalAlignment = Alignment.CenterHorizontally) { + FilledTertiaryIconButton(onClick = onClick) { + Icon( + imageVector = icon, + contentDescription = null, + ) + } + Text(text = text) + } + + @Composable + fun GalleryDetailComment(commentsList: List) { + val maxShowCount = 2 + val commentText = when { + commentsList.isEmpty() -> stringResource(R.string.no_comments) + commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments) + else -> stringResource(R.string.more_comment) + } + fun onNavigateToCommentScene() { + navigate(GalleryCommentsScreenDestination(galleryDetail.gid)) + } + CrystalCard { + commentsList.take(maxShowCount).forEach { item -> + GalleryCommentCard( + modifier = Modifier.padding(vertical = 4.dp), + comment = item, + onCardClick = ::onNavigateToCommentScene, + onUserClick = ::onNavigateToCommentScene, + onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) }, + ) { + maxLines = 5 + ellipsize = END + text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this)) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v)) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = ::onNavigateToCommentScene), + contentAlignment = Alignment.Center, + ) { + Text(commentText) + } + } + } + suspend fun showNewerVersionDialog() { + val items = galleryDetail.newerVersions.map { + getString(R.string.newer_version_title, it.title, it.posted) + } + val selected = awaitSelectItem(items) + val info = galleryDetail.newerVersions[selected] + withUIContext { + // Can't use GalleryInfoArgs as thumbKey is null + navigate(info.gid asDstWith info.token) + } + } + val keylineMargin = dimensionResource(R.dimen.keyline_margin) + Spacer(modifier = Modifier.size(keylineMargin)) + if (galleryDetail.newerVersions.isNotEmpty()) { + Box(contentAlignment = Alignment.Center) { + CrystalCard( + onClick = { launchIO { showNewerVersionDialog() } }, + modifier = Modifier.fillMaxWidth().height(32.dp), + ) { + } + Text(text = stringResource(id = R.string.newer_version_available)) + } + Spacer(modifier = Modifier.size(keylineMargin)) + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + ) { + val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it } + val favButtonText = if (favSlot != NOT_FAVORITED) { + galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites) + } else { + stringResource(id = R.string.not_favorited) + } + val favoritesLock = remember { MutatorMutex() } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val removeSucceed = stringResource(R.string.remove_from_favorite_success) + val addSucceed = stringResource(R.string.add_to_favorite_success) + // val removeFailed = stringResource(R.string.remove_from_favorite_failure) + val addFailed = stringResource(R.string.add_to_favorite_failure) + FilledTertiaryIconToggleButton( + checked = favSlot != NOT_FAVORITED, + onCheckedChange = { + launchIO { + favoritesLock.mutate { + runSuspendCatching { + modifyFavorites(galleryDetail.galleryInfo) + }.onSuccess { add -> + if (add) { + showSnackbar(addSucceed) + } else { + showSnackbar(removeSucceed) + } + }.onFailure { + // TODO: We don't know if it's add or remove + showSnackbar(addFailed) + } + } + } + }, + ) { + Icon( + imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED), + contentDescription = null, + ) + } + Text(text = favButtonText) + } + EhIconButton( + icon = Icons.Default.Search, + text = stringResource(id = R.string.similar_gallery), + onClick = { + val keyword = EhUtils.extractTitle(galleryDetail.title) + val artistTag = galleryDetail.tags.getArtistTag() + if (null != keyword) { + navigate( + ListUrlBuilder( + mode = ListUrlBuilder.MODE_NORMAL, + mKeyword = "\"" + keyword + "\"", + ).asDst(), + ) + } else if (artistTag != null) { + navigate( + ListUrlBuilder( + mode = ListUrlBuilder.MODE_TAG, + mKeyword = artistTag, + ).asDst(), + ) + } else if (null != galleryDetail.uploader) { + navigate( + ListUrlBuilder( + mode = ListUrlBuilder.MODE_UPLOADER, + mKeyword = galleryDetail.uploader, + ).asDst(), + ) + } + }, + ) + EhIconButton( + icon = Icons.Default.ImageSearch, + text = stringResource(id = R.string.search_cover), + onClick = { + val key = galleryDetail.thumbKey!! + navigate( + ListUrlBuilder( + mode = ListUrlBuilder.MODE_IMAGE_SEARCH, + hash = key.substringAfterLast('/').substringBefore('-'), + ).asDst(), + ) + }, + ) + val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount) + val permissionDenied = stringResource(R.string.permission_denied) + val downloadTorrentFailed = stringResource(R.string.download_torrent_failure) + val downloadTorrentStarted = stringResource(R.string.download_torrent_started) + val noTorrents = stringResource(R.string.no_torrents) + val torrentResult = remember(galleryDetail) { + async(Dispatchers.IO + Job(), CoroutineStart.LAZY) { + EhEngine.getTorrentList(galleryDetail.torrentUrl!!, galleryDetail.gid, galleryDetail.token) + } + } + suspend fun showTorrentDialog() { + val torrentList = bgWork { torrentResult.await() } + if (torrentList.isEmpty()) { + showSnackbar(noTorrents) + } else { + val selected = showNoButton(false) { + TorrentList( + items = torrentList, + onItemClick = { dismissWith(it) }, + ) + } + val url = selected.url + val name = "${selected.name}.torrent" + val r = android.app.DownloadManager.Request(url.toUri()) + r.setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name), + ) + r.setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) } + downloadManager.enqueue(r) + showSnackbar(downloadTorrentStarted) + } + } + EhIconButton( + icon = Icons.Default.SwapVerticalCircle, + text = torrentText, + onClick = { + launchIO { + if (galleryDetail.torrentCount > 0) { + val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (granted) { + runSuspendCatching { + showTorrentDialog() + }.onFailure { + logcat(it) + showSnackbar(downloadTorrentFailed) + } + } else { + showSnackbar(permissionDenied) + } + } else { + showSnackbar(noTorrents) + } + } + }, + ) + } + Spacer(modifier = Modifier.size(keylineMargin)) + fun getAllRatingText(rating: Float, ratingCount: Int): String = getString( + R.string.rating_text, + getString(getRatingText(rating)), + rating, + ratingCount, + ) + var ratingText by rememberSaveable { + mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount)) + } + val rateSucceed = stringResource(R.string.rate_successfully) + val rateFailed = stringResource(R.string.rate_failed) + val signInFirst = stringResource(R.string.sign_in_first) + fun showRateDialog() { + launchIO { + if (galleryDetail.apiUid < 0) { + showSnackbar(signInFirst) + return@launchIO + } + val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + var text by remember { mutableIntStateOf(getRatingText(expectedValue)) } + Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge) + Spacer(modifier = Modifier.size(keylineMargin)) + GalleryRatingBar( + rating = expectedValue, + onRatingChange = { + expectedValue = it.coerceAtLeast(.5f) + text = getRatingText(expectedValue) + }, + ) + } + } + galleryDetail.runSuspendCatching { + EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating) + }.onSuccess { result -> + galleryDetail.apply { + rating = result.rating + ratingCount = result.ratingCount + } + ratingText = getAllRatingText(result.rating, result.ratingCount) + showSnackbar(rateSucceed) + }.onFailure { + logcat(it) + showSnackbar(rateFailed) + } + } + } + CrystalCard(onClick = ::showRateDialog) { + Column( + modifier = Modifier.padding(8.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GalleryDetailRating(rating = galleryDetail.rating) + Spacer(modifier = Modifier.size(keylineMargin)) + Text(text = ratingText) + } + } + Spacer(modifier = Modifier.size(keylineMargin)) + val tags = galleryDetail.tags + if (tags.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(id = R.string.no_tags)) + } + } else { + val copy = stringResource(android.R.string.copy) + val copyTrans = stringResource(R.string.copy_trans) + val showDefine = stringResource(R.string.show_definition) + val addFilter = stringResource(R.string.add_filter) + val filterAdded = stringResource(R.string.filter_added) + val upTag = stringResource(R.string.tag_vote_up) + val downTag = stringResource(R.string.tag_vote_down) + GalleryTags( + tagGroups = tags, + onTagClick = { + navigate(ListUrlBuilder(mode = ListUrlBuilder.MODE_TAG, mKeyword = it).asDst()) + }, + onTagLongClick = { translated, tag -> + val index = tag.indexOf(':') + val temp = if (index >= 0) { + tag.substring(index + 1) + } else { + tag + } + launchIO { + awaitSelectAction { + onSelect(copy) { + addTextToClipboard(tag) + } + if (temp != translated) { + onSelect(copyTrans) { + addTextToClipboard(translated) + } + } + onSelect(showDefine) { + openBrowser(EhUrl.getTagDefinitionUrl(temp)) + } + onSelect(addFilter) { + awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) } + Filter(FilterMode.TAG, tag).remember() + showSnackbar(filterAdded) + } + if (galleryDetail.apiUid >= 0) { + onSelect(upTag) { galleryDetail.voteTag(tag, 1) } + onSelect(downTag) { galleryDetail.voteTag(tag, -1) } + } + }() + } + }, + ) + } + Spacer(modifier = Modifier.size(keylineMargin)) + if (Settings.showComments) { + GalleryDetailComment(galleryDetail.comments.comments) + Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v))) + } +} + +@StringRes +private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) { + 0 -> R.string.rating0 + 1 -> R.string.rating1 + 2 -> R.string.rating2 + 3 -> R.string.rating3 + 4 -> R.string.rating4 + 5 -> R.string.rating5 + 6 -> R.string.rating6 + 7 -> R.string.rating7 + 8 -> R.string.rating8 + 9 -> R.string.rating9 + 10 -> R.string.rating10 + else -> R.string.rating_none +} + +private fun List.getArtistTag(): String? { + for (tagGroup in this) { + if (tagGroup.isNotEmpty()) { + val namespace = tagGroup.groupName + if (namespace == TagNamespace.Artist.value || namespace == TagNamespace.Cosplayer.value) { + return "$namespace:${tagGroup[0].removePrefix("_")}" + } + } + } + return null +} + +@Composable +private fun Context.collectPreviewItems(detail: GalleryDetail, prefetchDistance: Int) = rememberInVM { + val pageSize = detail.previewList.size + val pages = detail.pages + val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap + Pager( + PagingConfig( + pageSize = pageSize, + prefetchDistance = prefetchDistance, + initialLoadSize = pageSize, + jumpThreshold = 2 * pageSize, + ), + ) { + object : PagingSource() { + override fun getRefreshKey(state: PagingState) = state.getClippedRefreshKey() + override suspend fun load(params: LoadParams): LoadResult = withIOContext { + val key = params.key ?: 0 + val up = getOffset(params, key, pages) + val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1) + detail.runSuspendCatching { + (up..end).filterNot { it in previewPagesMap }.map { it / pageSize }.toSet() + .parMap(concurrency = Settings.multiThreadDownload) { page -> + val url = EhUrl.getGalleryDetailUrl(gid, token, page, false) + EhEngine.getPreviewList(url).first + }.flattenForEach { + previewPagesMap[it.position] = it + if (Settings.preloadThumbAggressively) { + imageRequest(it) { justDownload() }.launchIn(viewModelScope) + } + } + }.foldToLoadResult { + val r = (up..end).map { requireNotNull(previewPagesMap[it]) } + val prevK = if (up <= 0 || r.isEmpty()) null else up + val nextK = if (end == pages - 1) null else end + 1 + LoadResult.Page(r, prevK, nextK, up, pages - end - 1) + } + } + override val jumpingSupported = true + } + }.flow.cachedIn(viewModelScope) +}.collectAsLazyPagingItems() + +private fun LazyGridScope.galleryPreview(data: LazyPagingItems, onClick: (Int) -> Unit) { + items( + count = data.itemCount, + key = data.itemKey(key = { item -> item.position }), + contentType = { "preview" }, + ) { index -> + val item = data[index] + EhPreviewItem(item, index) { onClick(index) } + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt index c720c8253..66db6faa7 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt @@ -1,171 +1,92 @@ package com.hippo.ehviewer.ui.screen -import android.Manifest import android.app.DownloadManager import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Environment import android.os.Parcelable -import android.text.TextUtils.TruncateAt.END import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.foundation.MutatorMutex -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridScope -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.FolderZip -import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.SwapVerticalCircle -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.LocalPinnableContainer -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.core.text.parseAsHtml -import androidx.window.core.layout.WindowWidthSizeClass -import arrow.core.partially1 import coil3.imageLoader import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache import com.hippo.ehviewer.EhApplication.Companion.imageCache import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine -import com.hippo.ehviewer.client.EhFilter.remember import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.BaseGalleryInfo -import com.hippo.ehviewer.client.data.GalleryComment import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.GalleryInfo -import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED -import com.hippo.ehviewer.client.data.GalleryTagGroup -import com.hippo.ehviewer.client.data.ListUrlBuilder -import com.hippo.ehviewer.client.data.TagNamespace -import com.hippo.ehviewer.client.data.asGalleryDetail import com.hippo.ehviewer.client.data.findBaseInfo import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.NoHAtHClientException import com.hippo.ehviewer.client.getImageKey import com.hippo.ehviewer.coil.justDownload -import com.hippo.ehviewer.collectAsState import com.hippo.ehviewer.dao.DownloadInfo -import com.hippo.ehviewer.dao.Filter -import com.hippo.ehviewer.dao.FilterMode import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager import com.hippo.ehviewer.ktbuilder.imageRequest import com.hippo.ehviewer.spider.SpiderDen -import com.hippo.ehviewer.ui.GalleryInfoBottomSheet import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.composing -import com.hippo.ehviewer.ui.confirmRemoveDownload -import com.hippo.ehviewer.ui.destinations.GalleryCommentsScreenDestination -import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination -import com.hippo.ehviewer.ui.getFavoriteIcon -import com.hippo.ehviewer.ui.jumpToReaderByPage -import com.hippo.ehviewer.ui.legacy.CoilImageGetter import com.hippo.ehviewer.ui.main.ArchiveList -import com.hippo.ehviewer.ui.main.EhPreviewItem -import com.hippo.ehviewer.ui.main.GalleryCommentCard import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip -import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard -import com.hippo.ehviewer.ui.main.GalleryTags -import com.hippo.ehviewer.ui.main.TorrentList -import com.hippo.ehviewer.ui.modifyFavorites import com.hippo.ehviewer.ui.navToReader import com.hippo.ehviewer.ui.openBrowser -import com.hippo.ehviewer.ui.startDownload -import com.hippo.ehviewer.ui.tools.CrystalCard -import com.hippo.ehviewer.ui.tools.EmptyWindowInsets -import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton -import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton -import com.hippo.ehviewer.ui.tools.GalleryDetailRating -import com.hippo.ehviewer.ui.tools.GalleryRatingBar -import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass import com.hippo.ehviewer.ui.tools.rememberInVM -import com.hippo.ehviewer.ui.tools.rememberLambda import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.AppHelper -import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.addTextToClipboard import com.hippo.ehviewer.util.awaitActivityResult import com.hippo.ehviewer.util.bgWork import com.hippo.ehviewer.util.displayString -import com.hippo.ehviewer.util.isAtLeastQ -import com.hippo.ehviewer.util.requestPermission import com.hippo.files.delete import com.hippo.files.toOkioPath import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.logcat -import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import moe.tarsin.coroutines.runSuspendCatching import splitties.systemservices.downloadManager @@ -184,34 +105,6 @@ data class TokenArgs( val page: Int = 0, ) : GalleryDetailScreenArgs -@StringRes -private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) { - 0 -> R.string.rating0 - 1 -> R.string.rating1 - 2 -> R.string.rating2 - 3 -> R.string.rating3 - 4 -> R.string.rating4 - 5 -> R.string.rating5 - 6 -> R.string.rating6 - 7 -> R.string.rating7 - 8 -> R.string.rating8 - 9 -> R.string.rating9 - 10 -> R.string.rating10 - else -> R.string.rating_none -} - -private fun List.getArtistTag(): String? { - for (tagGroup in this) { - if (tagGroup.isNotEmpty()) { - val namespace = tagGroup.groupName - if (namespace == TagNamespace.Artist.value || namespace == TagNamespace.Cosplayer.value) { - return "$namespace:${tagGroup[0].removePrefix("_")}" - } - } - } - return null -} - @Destination @Composable fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) { @@ -248,9 +141,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n var getDetailError by rememberSaveable { mutableStateOf("") } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - val voteSuccess = stringResource(R.string.tag_vote_successfully) - val voteFailed = stringResource(R.string.vote_failed) - if (galleryInfo !is GalleryDetail && getDetailError.isBlank()) { LaunchedEffect(Unit) { val galleryDetail = galleryDetailCache[gid] @@ -276,20 +166,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n } } - suspend fun GalleryDetail.voteTag(tag: String, vote: Int) { - runSuspendCatching { - EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote) - }.onSuccess { result -> - if (result != null) { - showSnackbar(result) - } else { - showSnackbar(voteSuccess) - } - }.onFailure { - showSnackbar(voteFailed) - } - } - val archiveResult = remember(galleryInfo) { async(Dispatchers.IO + Job(), CoroutineStart.LAZY) { val detail = galleryInfo as GalleryDetail @@ -362,610 +238,6 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n } } - val keylineMargin = dimensionResource(R.dimen.keyline_margin) - - fun navigateToPreview(nextPage: Boolean = false) { - (galleryInfo as? GalleryDetail)?.let { - navigator.navigate(GalleryPreviewScreenDestination(it, nextPage)) - } - } - - fun LazyGridScope.galleryDetailPreview(gd: GalleryDetail) { - val previewList = gd.previewList - items(previewList, key = { it.position }, contentType = { "preview" }) { - EhPreviewItem( - galleryPreview = it, - position = it.position, - onClick = { navToReader(gd.galleryInfo, it.position) }, - ) - } - item( - key = "footer", - span = { GridItemSpan(maxLineSpan) }, - contentType = "footer", - ) { - val footerText = if (gd.previewPages <= 0 || previewList.isEmpty()) { - stringResource(R.string.no_previews) - } else if (gd.previewPages == 1) { - stringResource(R.string.no_more_previews) - } else { - stringResource(R.string.more_previews) - } - TextButton(onClick = ::navigateToPreview.partially1(true)) { - Text(footerText) - } - } - } - - @Composable - fun BelowHeader(galleryDetail: GalleryDetail) { - @Composable - fun EhIconButton( - icon: ImageVector, - text: String, - onClick: () -> Unit, - ) = Column(horizontalAlignment = Alignment.CenterHorizontally) { - FilledTertiaryIconButton(onClick = onClick) { - Icon( - imageVector = icon, - contentDescription = null, - ) - } - Text(text = text) - } - - @Composable - fun GalleryDetailComment(commentsList: List) { - val maxShowCount = 2 - val commentText = when { - commentsList.isEmpty() -> stringResource(R.string.no_comments) - commentsList.size <= maxShowCount -> stringResource(R.string.no_more_comments) - else -> stringResource(R.string.more_comment) - } - fun onNavigateToCommentScene() { - navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid)) - } - CrystalCard { - commentsList.take(maxShowCount).forEach { item -> - GalleryCommentCard( - modifier = Modifier.padding(vertical = 4.dp), - comment = item, - onCardClick = ::onNavigateToCommentScene, - onUserClick = ::onNavigateToCommentScene, - onUrlClick = { if (!jumpToReaderByPage(it, galleryDetail)) if (!navWithUrl(it)) openBrowser(it) }, - ) { - maxLines = 5 - ellipsize = END - text = item.comment.parseAsHtml(imageGetter = CoilImageGetter(this)) - } - } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensionResource(id = R.dimen.strip_item_padding_v)) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = ::onNavigateToCommentScene), - contentAlignment = Alignment.Center, - ) { - Text(commentText) - } - } - } - suspend fun showNewerVersionDialog() { - val items = galleryDetail.newerVersions.map { - getString(R.string.newer_version_title, it.title, it.posted) - } - val selected = awaitSelectItem(items) - val info = galleryDetail.newerVersions[selected] - withUIContext { - // Can't use GalleryInfoArgs as thumbKey is null - navigator.navigate(info.gid asDstWith info.token) - } - } - Spacer(modifier = Modifier.size(keylineMargin)) - if (galleryDetail.newerVersions.isNotEmpty()) { - Box(contentAlignment = Alignment.Center) { - CrystalCard( - onClick = { launchIO { showNewerVersionDialog() } }, - modifier = Modifier.fillMaxWidth().height(32.dp), - ) { - } - Text(text = stringResource(id = R.string.newer_version_available)) - } - Spacer(modifier = Modifier.size(keylineMargin)) - } - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - ) { - val favSlot by FavouriteStatusRouter.collectAsState(galleryDetail) { it } - val favButtonText = if (favSlot != NOT_FAVORITED) { - galleryDetail.favoriteName ?: stringResource(id = R.string.local_favorites) - } else { - stringResource(id = R.string.not_favorited) - } - val favoritesLock = remember { MutatorMutex() } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - val removeSucceed = stringResource(R.string.remove_from_favorite_success) - val addSucceed = stringResource(R.string.add_to_favorite_success) - // val removeFailed = stringResource(R.string.remove_from_favorite_failure) - val addFailed = stringResource(R.string.add_to_favorite_failure) - FilledTertiaryIconToggleButton( - checked = favSlot != NOT_FAVORITED, - onCheckedChange = { - launchIO { - favoritesLock.mutate { - runSuspendCatching { - modifyFavorites(galleryDetail.galleryInfo) - }.onSuccess { add -> - if (add) { - showSnackbar(addSucceed) - } else { - showSnackbar(removeSucceed) - } - }.onFailure { - // TODO: We don't know if it's add or remove - showSnackbar(addFailed) - } - } - } - }, - ) { - Icon( - imageVector = getFavoriteIcon(favSlot != NOT_FAVORITED), - contentDescription = null, - ) - } - Text(text = favButtonText) - } - EhIconButton( - icon = Icons.Default.Search, - text = stringResource(id = R.string.similar_gallery), - onClick = { - val keyword = EhUtils.extractTitle(galleryDetail.title) - val artistTag = galleryDetail.tags.getArtistTag() - if (null != keyword) { - navigate( - ListUrlBuilder( - mode = ListUrlBuilder.MODE_NORMAL, - mKeyword = "\"" + keyword + "\"", - ).asDst(), - ) - } else if (artistTag != null) { - navigate( - ListUrlBuilder( - mode = ListUrlBuilder.MODE_TAG, - mKeyword = artistTag, - ).asDst(), - ) - } else if (null != galleryDetail.uploader) { - navigate( - ListUrlBuilder( - mode = ListUrlBuilder.MODE_UPLOADER, - mKeyword = galleryDetail.uploader, - ).asDst(), - ) - } - }, - ) - EhIconButton( - icon = Icons.Default.ImageSearch, - text = stringResource(id = R.string.search_cover), - onClick = { - val key = galleryDetail.thumbKey!! - navigator.navigate( - ListUrlBuilder( - mode = ListUrlBuilder.MODE_IMAGE_SEARCH, - hash = key.substringAfterLast('/').substringBefore('-'), - ).asDst(), - ) - }, - ) - val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount) - val permissionDenied = stringResource(R.string.permission_denied) - val downloadTorrentFailed = stringResource(R.string.download_torrent_failure) - val downloadTorrentStarted = stringResource(R.string.download_torrent_started) - val noTorrents = stringResource(R.string.no_torrents) - val torrentResult = remember(galleryDetail) { - async(Dispatchers.IO + Job(), CoroutineStart.LAZY) { - EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token) - } - } - suspend fun showTorrentDialog() { - val torrentList = bgWork { torrentResult.await() } - if (torrentList.isEmpty()) { - showSnackbar(noTorrents) - } else { - val selected = showNoButton(false) { - TorrentList( - items = torrentList, - onItemClick = { dismissWith(it) }, - ) - } - val url = selected.url - val name = "${selected.name}.torrent" - val r = DownloadManager.Request(url.toUri()) - r.setDestinationInExternalPublicDir( - Environment.DIRECTORY_DOWNLOADS, - AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name), - ) - r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) } - downloadManager.enqueue(r) - showSnackbar(downloadTorrentStarted) - } - } - EhIconButton( - icon = Icons.Default.SwapVerticalCircle, - text = torrentText, - onClick = { - launchIO { - if (galleryDetail.torrentCount > 0) { - val granted = isAtLeastQ || requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - if (granted) { - runSuspendCatching { - showTorrentDialog() - }.onFailure { - logcat(it) - showSnackbar(downloadTorrentFailed) - } - } else { - showSnackbar(permissionDenied) - } - } else { - showSnackbar(noTorrents) - } - } - }, - ) - } - Spacer(modifier = Modifier.size(keylineMargin)) - fun getAllRatingText(rating: Float, ratingCount: Int): String = getString( - R.string.rating_text, - getString(getRatingText(rating)), - rating, - ratingCount, - ) - var ratingText by rememberSaveable { - mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount)) - } - val rateSucceed = stringResource(R.string.rate_successfully) - val rateFailed = stringResource(R.string.rate_failed) - fun showRateDialog() { - launchIO { - if (galleryDetail.apiUid < 0) { - showSnackbar(signInFirst) - return@launchIO - } - val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - var text by remember { mutableIntStateOf(getRatingText(expectedValue)) } - Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge) - Spacer(modifier = Modifier.size(keylineMargin)) - GalleryRatingBar( - rating = expectedValue, - onRatingChange = { - expectedValue = it.coerceAtLeast(.5f) - text = getRatingText(expectedValue) - }, - ) - } - } - galleryDetail.runSuspendCatching { - EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating) - }.onSuccess { result -> - galleryInfo = galleryDetail.apply { - rating = result.rating - ratingCount = result.ratingCount - } - ratingText = getAllRatingText(result.rating, result.ratingCount) - showSnackbar(rateSucceed) - }.onFailure { - logcat(it) - showSnackbar(rateFailed) - } - } - } - CrystalCard(onClick = ::showRateDialog) { - Column( - modifier = Modifier.padding(8.dp).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - GalleryDetailRating(rating = galleryDetail.rating) - Spacer(modifier = Modifier.size(keylineMargin)) - Text(text = ratingText) - } - } - Spacer(modifier = Modifier.size(keylineMargin)) - val tags = galleryDetail.tags - if (tags.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Text(text = stringResource(id = R.string.no_tags)) - } - } else { - val copy = stringResource(android.R.string.copy) - val copyTrans = stringResource(R.string.copy_trans) - val showDefine = stringResource(R.string.show_definition) - val addFilter = stringResource(R.string.add_filter) - val filterAdded = stringResource(R.string.filter_added) - val upTag = stringResource(R.string.tag_vote_up) - val downTag = stringResource(R.string.tag_vote_down) - GalleryTags( - tagGroups = tags, - onTagClick = { - val lub = ListUrlBuilder() - lub.mode = ListUrlBuilder.MODE_TAG - lub.keyword = it - navigate(lub.asDst()) - }, - onTagLongClick = { translated, tag -> - val index = tag.indexOf(':') - val temp = if (index >= 0) { - tag.substring(index + 1) - } else { - tag - } - launchIO { - awaitSelectAction { - onSelect(copy) { - addTextToClipboard(tag) - } - if (temp != translated) { - onSelect(copyTrans) { - addTextToClipboard(translated) - } - } - onSelect(showDefine) { - openBrowser(EhUrl.getTagDefinitionUrl(temp)) - } - onSelect(addFilter) { - awaitPermissionOrCancel { Text(text = stringResource(R.string.filter_the_tag, tag)) } - Filter(FilterMode.TAG, tag).remember() - showSnackbar(filterAdded) - } - if (galleryDetail.apiUid >= 0) { - onSelect(upTag) { galleryDetail.voteTag(tag, 1) } - onSelect(downTag) { galleryDetail.voteTag(tag, -1) } - } - }() - } - }, - ) - } - Spacer(modifier = Modifier.size(keylineMargin)) - if (Settings.showComments) { - GalleryDetailComment(galleryDetail.comments.comments) - Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v))) - } - } - - @Composable - fun GalleryDetailContent( - galleryInfo: GalleryInfo, - contentPadding: PaddingValues, - modifier: Modifier, - ) { - val galleryDetail = galleryInfo.asGalleryDetail() - val windowSizeClass = LocalWindowSizeClass.current - val thumbColumns by Settings.thumbColumns.collectAsState() - val readText = stringResource(R.string.read) - val startPage by rememberInVM { - EhDB.getReadProgressFlow(galleryInfo.gid) - }.collectAsState(0) - val readButtonText = if (startPage == 0) { - readText - } else { - stringResource(R.string.read_from, startPage + 1) - } - val downloadState by EhDownloadManager.collectDownloadState(gid) - val downloadButtonText = when (downloadState) { - DownloadInfo.STATE_INVALID -> stringResource(R.string.download) - DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none) - DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait) - DownloadInfo.STATE_DOWNLOAD -> stringResource(R.string.download_state_downloading) - DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_downloaded) - DownloadInfo.STATE_FAILED -> stringResource(R.string.download_state_failed) - else -> error("Invalid DownloadState!!!") - } - fun onReadButtonClick() { - if (galleryDetail != null || downloadState != DownloadInfo.STATE_INVALID) { - navToReader(galleryInfo.findBaseInfo(), startPage) - } - } - fun onCategoryChipClick() { - val category = galleryInfo.category - if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) { - return - } - val lub = ListUrlBuilder(category = category) - navigator.navigate(lub.asDst()) - } - fun onUploaderChipClick(galleryInfo: GalleryInfo) { - val uploader = galleryInfo.uploader - val disowned = uploader == "(Disowned)" - if (uploader.isNullOrEmpty() || disowned) { - return - } - val lub = ListUrlBuilder() - lub.mode = ListUrlBuilder.MODE_UPLOADER - lub.keyword = uploader - navigator.navigate(lub.asDst()) - } - - var showBottomSheet by remember { mutableStateOf(false) } - - if (showBottomSheet && galleryDetail != null) { - ModalBottomSheet( - onDismissRequest = { showBottomSheet = false }, - contentWindowInsets = { EmptyWindowInsets }, - ) { - GalleryInfoBottomSheet(galleryDetail, navigator) - } - } - - fun onGalleryInfoCardClick() { - showBottomSheet = true - } - - val filterAdded = stringResource(R.string.filter_added) - fun showFilterUploaderDialog(galleryInfo: GalleryInfo) { - val uploader = galleryInfo.uploader - val disowned = uploader == "(Disowned)" - if (uploader.isNullOrEmpty() || disowned) { - return - } - launchIO { - awaitPermissionOrCancel { - Text(text = stringResource(R.string.filter_the_uploader, uploader)) - } - Filter(FilterMode.UPLOADER, uploader).remember() - showSnackbar(filterAdded) - } - } - val onDownloadButtonClick = rememberLambda(galleryInfo) { - galleryDetail ?: return@rememberLambda - if (EhDownloadManager.getDownloadState(galleryDetail.gid) == DownloadInfo.STATE_INVALID) { - launchUI { startDownload(implicit(), false, galleryDetail.galleryInfo) } - } else { - launch { confirmRemoveDownload(galleryDetail) } - } - } - - when (windowSizeClass.windowWidthSizeClass) { - WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid( - columns = GridCells.Fixed(thumbColumns), - contentPadding = contentPadding, - modifier = modifier.padding(horizontal = keylineMargin), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)), - ) { - item( - key = "header", - span = { GridItemSpan(maxCurrentLineSpan) }, - contentType = "header", - ) { - GalleryDetailHeaderCard( - info = galleryInfo, - onInfoCardClick = ::onGalleryInfoCardClick, - onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo), - onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo), - onCategoryChipClick = ::onCategoryChipClick, - modifier = Modifier.fillMaxWidth().padding(vertical = keylineMargin), - ) - } - item( - key = "body", - span = { GridItemSpan(maxCurrentLineSpan) }, - contentType = "body", - ) { - LocalPinnableContainer.current!!.run { remember { pin() } } - Column { - Row { - FilledTonalButton( - onClick = onDownloadButtonClick, - modifier = Modifier.padding(horizontal = 4.dp).weight(1F), - ) { - Text(text = downloadButtonText, maxLines = 1) - } - Button( - onClick = ::onReadButtonClick, - modifier = Modifier.padding(horizontal = 4.dp).weight(1F), - ) { - Text(text = readButtonText, maxLines = 1) - } - } - if (getDetailError.isNotBlank()) { - GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" }) - } else if (galleryDetail != null) { - BelowHeader(galleryDetail) - } else { - Box( - modifier = Modifier.fillMaxSize().padding(keylineMargin), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - } - } - if (galleryDetail != null) { - galleryDetailPreview(galleryDetail) - } - } - - WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid( - columns = GridCells.Fixed(thumbColumns), - contentPadding = contentPadding, - modifier = modifier.padding(horizontal = keylineMargin), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.strip_item_padding_v)), - ) { - item( - key = "header", - span = { GridItemSpan(maxCurrentLineSpan) }, - contentType = "header", - ) { - Row(modifier = Modifier.fillMaxWidth()) { - GalleryDetailHeaderCard( - info = galleryInfo, - onInfoCardClick = ::onGalleryInfoCardClick, - onUploaderChipClick = ::onUploaderChipClick.partially1(galleryInfo), - onBlockUploaderIconClick = ::showFilterUploaderDialog.partially1(galleryInfo), - onCategoryChipClick = ::onCategoryChipClick, - modifier = Modifier.width(dimensionResource(id = R.dimen.gallery_detail_card_landscape_width)).padding(vertical = keylineMargin), - ) - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = modifier.height(16.dp)) - Button( - onClick = ::onReadButtonClick, - modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp), - ) { - Text(text = readButtonText, maxLines = 1) - } - Spacer(modifier = modifier.height(24.dp)) - FilledTonalButton( - onClick = onDownloadButtonClick, - modifier = Modifier.height(56.dp).padding(horizontal = 16.dp).width(192.dp), - ) { - Text(text = downloadButtonText, maxLines = 1) - } - } - } - } - item( - key = "body", - span = { GridItemSpan(maxCurrentLineSpan) }, - contentType = "body", - ) { - LocalPinnableContainer.current!!.run { remember { pin() } } - Column { - if (getDetailError.isNotBlank()) { - GalleryDetailErrorTip(error = getDetailError, onClick = { getDetailError = "" }) - } else if (galleryDetail != null) { - BelowHeader(galleryDetail) - } else { - Box( - modifier = Modifier.fillMaxSize().padding(keylineMargin), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - } - } - if (galleryDetail != null) { - galleryDetailPreview(galleryDetail) - } - } - } - } - Scaffold( topBar = { LargeTopAppBar( @@ -1128,6 +400,8 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n GalleryDetailContent( galleryInfo = gi, contentPadding = it, + getDetailError = getDetailError, + onRetry = { getDetailError = "" }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) } else if (getDetailError.isNotBlank()) { @@ -1142,3 +416,18 @@ fun AnimatedVisibilityScope.GalleryDetailScreen(args: GalleryDetailScreenArgs, n } } } + +context(Context, SnackbarHostState) +suspend fun GalleryDetail.voteTag(tag: String, vote: Int) { + runSuspendCatching { + EhEngine.voteTag(apiUid, apiKey, gid, token, tag, vote) + }.onSuccess { result -> + if (result != null) { + showSnackbar(result) + } else { + showSnackbar(getString(R.string.tag_vote_successfully)) + } + }.onFailure { + showSnackbar(getString(R.string.vote_failed)) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryPreviewScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryPreviewScreen.kt deleted file mode 100644 index 08ee9efe9..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryPreviewScreen.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.hippo.ehviewer.ui.screen - -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource -import androidx.paging.PagingState -import androidx.paging.cachedIn -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey -import arrow.fx.coroutines.parMap -import com.hippo.ehviewer.R -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.client.EhEngine -import com.hippo.ehviewer.client.EhUrl -import com.hippo.ehviewer.client.data.GalleryDetail -import com.hippo.ehviewer.client.data.GalleryPreview -import com.hippo.ehviewer.coil.justDownload -import com.hippo.ehviewer.collectAsState -import com.hippo.ehviewer.ktbuilder.imageRequest -import com.hippo.ehviewer.ktbuilder.launchIn -import com.hippo.ehviewer.ui.composing -import com.hippo.ehviewer.ui.main.EhPreviewItem -import com.hippo.ehviewer.ui.navToReader -import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid -import com.hippo.ehviewer.ui.tools.foldToLoadResult -import com.hippo.ehviewer.ui.tools.getClippedRefreshKey -import com.hippo.ehviewer.ui.tools.getLimit -import com.hippo.ehviewer.ui.tools.getOffset -import com.hippo.ehviewer.ui.tools.rememberInVM -import com.hippo.ehviewer.util.flattenForEach -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import eu.kanade.tachiyomi.util.lang.withIOContext -import moe.tarsin.coroutines.runSuspendCatching - -@Destination -@Composable -fun AnimatedVisibilityScope.GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) { - val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior() - val pgSize = detail.previewList.size - val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0) - val thumbColumns by Settings.thumbColumns.collectAsState() - val data = rememberInVM { - val pages = detail.pages - val previewPagesMap = detail.previewList.associateBy { it.position } as MutableMap - Pager( - PagingConfig( - pageSize = pgSize, - prefetchDistance = thumbColumns, - initialLoadSize = pgSize, - jumpThreshold = 2 * pgSize, - ), - ) { - object : PagingSource() { - override fun getRefreshKey(state: PagingState) = state.getClippedRefreshKey() - override suspend fun load(params: LoadParams): LoadResult = withIOContext { - val key = params.key ?: 0 - val up = getOffset(params, key, pages) - val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1) - detail.runSuspendCatching { - (up..end).filterNot { it in previewPagesMap }.map { it / pgSize }.toSet() - .parMap(concurrency = Settings.multiThreadDownload) { page -> - val url = EhUrl.getGalleryDetailUrl(gid, token, page, false) - EhEngine.getPreviewList(url).first - }.flattenForEach { - previewPagesMap[it.position] = it - if (Settings.preloadThumbAggressively) { - imageRequest(it) { justDownload() }.launchIn(viewModelScope) - } - } - }.foldToLoadResult { - val r = (up..end).map { requireNotNull(previewPagesMap[it]) } - val prevK = if (up <= 0 || r.isEmpty()) null else up - val nextK = if (end == pages - 1) null else end + 1 - LoadResult.Page(r, prevK, nextK, up, pages - end - 1) - } - } - override val jumpingSupported = true - } - }.flow.cachedIn(viewModelScope) - }.collectAsLazyPagingItems() - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.gallery_previews)) }, - navigationIcon = { - IconButton(onClick = { popBackStack() }) { - Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) - } - }, - scrollBehavior = scrollBehaviour, - ) - }, - ) { paddingValues -> - FastScrollLazyVerticalGrid( - columns = GridCells.Fixed(thumbColumns), - modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)), - state = state, - contentPadding = paddingValues, - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items( - count = data.itemCount, - key = data.itemKey(key = { item -> item.position }), - contentType = data.itemContentType(), - ) { index -> - val item = data[index] - EhPreviewItem(item, index) { navToReader(detail.galleryInfo, index) } - } - } - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/RememberLambda.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/RememberLambda.kt deleted file mode 100644 index e52350938..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/RememberLambda.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.hippo.ehviewer.ui.tools - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisallowComposableCalls -import androidx.compose.runtime.cache -import androidx.compose.runtime.currentComposer - -@Composable -fun rememberLambda(calculation: @DisallowComposableCalls () -> Unit): () -> Unit = - currentComposer.cache(false) { calculation } - -@Composable -fun rememberLambda( - key1: Any?, - calculation: @DisallowComposableCalls () -> Unit, -): () -> Unit = currentComposer.cache(currentComposer.changed(key1)) { calculation }