Merge GalleryPreviewScreen to GalleryDetailScreen (#1649)

This commit is contained in:
FooIbar authored and GitHub committed 2024-09-19 23:39:42 +08:00
1 parent e4eacce5bf
commit 3a42208a36
5 files changed
+825 -888

No files matched your search

@@ -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()
@@ -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<MainActivity>(), 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<GalleryComment>) {
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<GalleryTagGroup>.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<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = 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<GalleryPreview>, 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) }
}
}
@@ -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<GalleryTagGroup>.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<RootGraph>
@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<GalleryComment>) {
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<MainActivity>(), 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))
}
}
@@ -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<RootGraph>
@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<Int, GalleryPreview>() {
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> = 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) }
}
}
}
}
@@ -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 }