Some changes
parent
9e269a9e03
commit
57e490fe28
@ -0,0 +1,95 @@
|
||||
package org.xtimms.tokusho.core.cache
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.StatFs
|
||||
import com.tomclaw.cache.DiskLruCache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.tokusho.utils.FileSize
|
||||
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||
import org.xtimms.tokusho.utils.lang.writeAllCancellable
|
||||
import org.xtimms.tokusho.utils.system.compressToPNG
|
||||
import org.xtimms.tokusho.utils.system.subdir
|
||||
import org.xtimms.tokusho.utils.system.takeIfReadable
|
||||
import org.xtimms.tokusho.utils.system.takeIfWriteable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val cacheDir = SuspendLazy {
|
||||
val dirs = context.externalCacheDirs + context.cacheDir
|
||||
dirs.firstNotNullOf {
|
||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||
}
|
||||
}
|
||||
private val lruCache = SuspendLazy {
|
||||
val dir = cacheDir.get()
|
||||
val availableSize = (getAvailableSize() * 0.8).toLong()
|
||||
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
|
||||
runCatchingCancellable {
|
||||
DiskLruCache.create(dir, size)
|
||||
}.recoverCatching { error ->
|
||||
error.printStackTrace()
|
||||
dir.deleteRecursively()
|
||||
dir.mkdir()
|
||||
DiskLruCache.create(dir, size)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun get(url: String): File? {
|
||||
val cache = lruCache.get()
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
cache.get(url)?.takeIfReadable()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
|
||||
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||
try {
|
||||
val bytes = file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(source)
|
||||
}
|
||||
check(bytes != 0L) { "No data has been written" }
|
||||
lruCache.get().put(url, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
|
||||
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||
try {
|
||||
bitmap.compressToPNG(file)
|
||||
lruCache.get().put(url, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
|
||||
val statFs = StatFs(cacheDir.get().absolutePath)
|
||||
statFs.availableBytes
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}.getOrDefault(SIZE_DEFAULT)
|
||||
|
||||
private companion object {
|
||||
|
||||
val SIZE_MIN
|
||||
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
|
||||
|
||||
val SIZE_DEFAULT
|
||||
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.LocalLibrary
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.FloatingActionButtonElevation
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
|
||||
import org.xtimms.tokusho.sections.reader.READER_DESTINATION
|
||||
|
||||
@Composable
|
||||
fun ContinueReadingButton(
|
||||
navController: NavController,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val isVisible by remember {
|
||||
derivedStateOf {
|
||||
when (navBackStackEntry?.destination?.route) {
|
||||
HISTORY_DESTINATION, null -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fabScale by animateFloatAsState(
|
||||
targetValue = when (navBackStackEntry?.destination?.route) {
|
||||
HISTORY_DESTINATION, null -> 1f
|
||||
else -> 0f
|
||||
},
|
||||
animationSpec = tween(150), label = "elevation"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) +
|
||||
scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(300, delayMillis = 150)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(0))
|
||||
) {
|
||||
androidx.compose.material3.ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
READER_DESTINATION
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(8.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 4.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocalLibrary,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.continue_reading),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ListGroupHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package org.xtimms.tokusho.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.xtimms.tokusho.core.BottomNavDestination
|
||||
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
|
||||
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
|
||||
|
||||
@Composable
|
||||
fun NavigationRail(
|
||||
navController: NavController,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
NavigationRail(
|
||||
header = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
navController.navigate(SEARCH_DESTINATION) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
BottomNavDestination.railValues.forEachIndexed { index, dest ->
|
||||
val isSelected = navBackStackEntry?.destination?.route == dest.route
|
||||
NavigationRailItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { dest.Icon(selected = isSelected) },
|
||||
label = { Text(text = stringResource(dest.title)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package org.xtimms.tokusho.core.components.effects
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xtimms.tokusho.sections.history.HistoryItemModel
|
||||
import java.time.Instant
|
||||
|
||||
enum class RowEntityType { Header, Item }
|
||||
|
||||
data class RowEntity(
|
||||
val type: RowEntityType,
|
||||
val key: String,
|
||||
var contentHash: String? = null,
|
||||
val day: Instant,
|
||||
var historyItemModel: HistoryItemModel?,
|
||||
)
|
||||
|
||||
@SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter")
|
||||
/**
|
||||
* @param state Use [updateAnimatedItemsState].
|
||||
*/
|
||||
inline fun LazyListScope.animatedItemsIndexed(
|
||||
state: List<AnimatedItem<RowEntity>>,
|
||||
enterTransition: EnterTransition = expandVertically() + fadeIn(),
|
||||
exitTransition: ExitTransition = shrinkVertically() + fadeOut(),
|
||||
noinline key: ((item: RowEntity) -> Any)? = null,
|
||||
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: RowEntity) -> Unit
|
||||
) {
|
||||
items(
|
||||
state.size,
|
||||
if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null
|
||||
) { index ->
|
||||
|
||||
val item = state[index]
|
||||
|
||||
key(key?.invoke(item.item)) {
|
||||
AnimatedVisibility(
|
||||
visibleState = item.visibility,
|
||||
enter = enterTransition,
|
||||
exit = exitTransition
|
||||
) {
|
||||
itemContent(index, item.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun updateAnimatedItemsState(
|
||||
newList: List<RowEntity>
|
||||
): State<List<AnimatedItem<RowEntity>>> {
|
||||
|
||||
val state = remember { mutableStateOf(emptyList<AnimatedItem<RowEntity>>()) }
|
||||
val firstInject = remember { mutableStateOf(true) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
state.value = emptyList()
|
||||
onDispose {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(newList) {
|
||||
if (state.value == newList) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val oldList = state.value.toList()
|
||||
|
||||
val diffCb = object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item.key == newList[newItemPosition].key
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
(oldList[oldItemPosition].item.contentHash
|
||||
?: oldList[oldItemPosition].item.key) == (newList[newItemPosition].contentHash
|
||||
?: newList[newItemPosition].key)
|
||||
|
||||
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): RowEntity =
|
||||
newList[newItemPosition]
|
||||
}
|
||||
val diffResult = calculateDiff(false, diffCb)
|
||||
val compositeList = oldList.toMutableList()
|
||||
|
||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
val newItem = AnimatedItem(
|
||||
visibility = MutableTransitionState(firstInject.value),
|
||||
newList[position + i]
|
||||
)
|
||||
newItem.visibility.targetState = true
|
||||
compositeList.add(position + i, newItem)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].visibility.targetState = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
// not detecting moves.
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].item.historyItemModel = (payload as RowEntity).historyItemModel
|
||||
compositeList[position + i].item.contentHash = payload.contentHash
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (state.value != compositeList) {
|
||||
state.value = compositeList
|
||||
}
|
||||
firstInject.value = false
|
||||
val initialAnimation = androidx.compose.animation.core.Animatable(1.0f)
|
||||
initialAnimation.animateTo(0f)
|
||||
state.value = state.value.filter { it.visibility.targetState }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
data class AnimatedItem<T>(
|
||||
val visibility: MutableTransitionState<Boolean>,
|
||||
val item: T,
|
||||
) {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return item?.hashCode() ?: 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AnimatedItem<*>
|
||||
|
||||
if (item != other.item) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun calculateDiff(
|
||||
detectMoves: Boolean = true,
|
||||
diffCb: DiffUtil.Callback
|
||||
): DiffUtil.DiffResult {
|
||||
return withContext(Dispatchers.Unconfined) {
|
||||
DiffUtil.calculateDiff(diffCb, detectMoves)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.xtimms.tokusho.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaWithHistory(
|
||||
val manga: Manga,
|
||||
val history: MangaHistory
|
||||
)
|
||||
@ -0,0 +1,40 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* When [visible] becomes false, if transition is running, delay the exit of the content until
|
||||
* transition finishes. Note that you may need to call [SharedElementsRootScope.prepareTransition]
|
||||
* before [visible] becomes false to start transition immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedElementsRootScope.DelayExit(
|
||||
visible: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var state by remember { mutableStateOf(DelayExitState.Invisible) }
|
||||
|
||||
when (state) {
|
||||
DelayExitState.Invisible -> {
|
||||
if (visible) state = DelayExitState.Visible
|
||||
}
|
||||
DelayExitState.Visible -> {
|
||||
if (!visible) {
|
||||
state = if (isRunningTransition) DelayExitState.ExitDelayed else DelayExitState.Invisible
|
||||
}
|
||||
}
|
||||
DelayExitState.ExitDelayed -> {
|
||||
if (!isRunningTransition) state = DelayExitState.Invisible
|
||||
}
|
||||
}
|
||||
|
||||
if (state != DelayExitState.Invisible) content()
|
||||
}
|
||||
|
||||
private enum class DelayExitState {
|
||||
Invisible, Visible, ExitDelayed
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
internal fun ElementContainer(
|
||||
modifier: Modifier,
|
||||
relaxMaxSize: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(content, modifier) { measurables, constraints ->
|
||||
if (measurables.size > 1) {
|
||||
throw IllegalStateException("SharedElement can have only one direct measurable child!")
|
||||
}
|
||||
val placeable = measurables.firstOrNull()?.measure(
|
||||
Constraints(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxWidth = if (relaxMaxSize) Constraints.Infinity else constraints.maxWidth,
|
||||
maxHeight = if (relaxMaxSize) Constraints.Infinity else constraints.maxHeight
|
||||
)
|
||||
)
|
||||
val width = min(max(constraints.minWidth, placeable?.width ?: 0), constraints.maxWidth)
|
||||
val height = min(max(constraints.minHeight, placeable?.height ?: 0), constraints.maxHeight)
|
||||
layout(width, height) {
|
||||
placeable?.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
|
||||
abstract class KeyframeBasedMotion : PathMotion {
|
||||
|
||||
private var start = Offset.Unspecified
|
||||
private var end = Offset.Unspecified
|
||||
private var keyframes: Pair<FloatArray, LongArray>? = null
|
||||
|
||||
protected abstract fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray>
|
||||
|
||||
private fun LongArray.getOffset(index: Int) =
|
||||
@Suppress("INVISIBLE_MEMBER") Offset(get(index))
|
||||
|
||||
override fun invoke(start: Offset, end: Offset, fraction: Float): Offset {
|
||||
var frac = fraction
|
||||
if (start != this.start || end != this.end) {
|
||||
if (start == this.end && end == this.start) {
|
||||
frac = 1 - frac
|
||||
} else {
|
||||
keyframes = null
|
||||
this.start = start
|
||||
this.end = end
|
||||
}
|
||||
}
|
||||
val (fractions, offsets) = keyframes ?: getKeyframes(start, end).also { keyframes = it }
|
||||
val count = fractions.size
|
||||
|
||||
return when {
|
||||
frac < 0f -> interpolateInRange(fractions, offsets, frac, 0, 1)
|
||||
frac > 1f -> interpolateInRange(fractions, offsets, frac, count - 2, count - 1)
|
||||
frac == 0f -> offsets.getOffset(0)
|
||||
frac == 1f -> offsets.getOffset(count - 1)
|
||||
else -> {
|
||||
// Binary search for the correct section
|
||||
var low = 0
|
||||
var high = count - 1
|
||||
while (low <= high) {
|
||||
val mid = (low + high) / 2
|
||||
val midFraction = fractions[mid]
|
||||
|
||||
when {
|
||||
frac < midFraction -> high = mid - 1
|
||||
frac > midFraction -> low = mid + 1
|
||||
else -> return offsets.getOffset(mid)
|
||||
}
|
||||
}
|
||||
|
||||
// now high is below the fraction and low is above the fraction
|
||||
interpolateInRange(fractions, offsets, frac, high, low)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interpolateInRange(
|
||||
fractions: FloatArray, offsets: LongArray,
|
||||
fraction: Float, startIndex: Int, endIndex: Int
|
||||
): Offset {
|
||||
val startFraction = fractions[startIndex]
|
||||
val endFraction = fractions[endIndex]
|
||||
val intervalFraction = (fraction - startFraction) / (endFraction - startFraction)
|
||||
val start = offsets.getOffset(startIndex)
|
||||
val end = offsets.getOffset(endIndex)
|
||||
return lerp(start, end, intervalFraction)
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
class MaterialArcMotion : KeyframeBasedMotion() {
|
||||
|
||||
override fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray> =
|
||||
QuadraticBezier.approximate(
|
||||
start,
|
||||
if (start.y > end.y) Offset(end.x, start.y) else Offset(start.x, end.y),
|
||||
end,
|
||||
0.5f
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
val MaterialArcMotionFactory: PathMotionFactory = { MaterialArcMotion() }
|
||||
@ -0,0 +1,60 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.ScaleFactor
|
||||
import androidx.compose.ui.layout.lerp
|
||||
|
||||
internal val Rect.area: Float
|
||||
get() = width * height
|
||||
|
||||
internal operator fun Size.div(operand: Size): ScaleFactor =
|
||||
ScaleFactor(width / operand.width, height / operand.height)
|
||||
|
||||
internal fun calculateDirection(start: Rect, end: Rect): TransitionDirection =
|
||||
if (end.area > start.area) TransitionDirection.Enter else TransitionDirection.Return
|
||||
|
||||
internal fun calculateAlpha(
|
||||
direction: TransitionDirection?,
|
||||
fadeMode: FadeMode?,
|
||||
fraction: Float, // Absolute
|
||||
isStart: Boolean
|
||||
) = when (fadeMode) {
|
||||
FadeMode.In, null -> if (isStart) 1f else fraction
|
||||
FadeMode.Out -> if (isStart) 1 - fraction else 1f
|
||||
FadeMode.Cross -> if (isStart) 1 - fraction else fraction
|
||||
FadeMode.Through -> {
|
||||
val threshold = if (direction == TransitionDirection.Enter)
|
||||
FadeThroughProgressThreshold else 1 - FadeThroughProgressThreshold
|
||||
if (fraction < threshold) {
|
||||
if (isStart) 1 - fraction / threshold else 0f
|
||||
} else {
|
||||
if (isStart) 0f else (fraction - threshold) / (1 - threshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateOffset(
|
||||
start: Rect,
|
||||
end: Rect?,
|
||||
fraction: Float, // Relative
|
||||
pathMotion: PathMotion?,
|
||||
width: Float
|
||||
): Offset = if (end == null) start.topLeft else {
|
||||
val topCenter = pathMotion!!.invoke(
|
||||
start.topCenter,
|
||||
end.topCenter,
|
||||
fraction
|
||||
)
|
||||
Offset(topCenter.x - width / 2, topCenter.y)
|
||||
}
|
||||
|
||||
internal val Identity = ScaleFactor(1f, 1f)
|
||||
|
||||
internal fun calculateScale(
|
||||
start: Rect,
|
||||
end: Rect?,
|
||||
fraction: Float // Relative
|
||||
): ScaleFactor =
|
||||
if (end == null) Identity else lerp(Identity, end.size / start.size, fraction)
|
||||
@ -0,0 +1,12 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
|
||||
typealias PathMotion = (start: Offset, end: Offset, fraction: Float) -> Offset
|
||||
|
||||
typealias PathMotionFactory = () -> PathMotion
|
||||
|
||||
val LinearMotion: PathMotion = ::lerp
|
||||
|
||||
val LinearMotionFactory: PathMotionFactory = { LinearMotion }
|
||||
@ -0,0 +1,39 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.util.packFloats
|
||||
import androidx.compose.ui.util.unpackFloat1
|
||||
import androidx.compose.ui.util.unpackFloat2
|
||||
|
||||
@JvmInline
|
||||
@Immutable
|
||||
value class ProgressThresholds(private val packedValue: Long) {
|
||||
|
||||
@Stable
|
||||
val start: Float
|
||||
get() = unpackFloat1(packedValue)
|
||||
|
||||
@Stable
|
||||
val end: Float
|
||||
get() = unpackFloat2(packedValue)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Stable
|
||||
inline operator fun component1(): Float = start
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Stable
|
||||
inline operator fun component2(): Float = end
|
||||
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun ProgressThresholds(start: Float, end: Float) = ProgressThresholds(packFloats(start, end))
|
||||
|
||||
@Stable
|
||||
internal fun ProgressThresholds.applyTo(fraction: Float): Float = when {
|
||||
fraction < start -> 0f
|
||||
fraction in start..end -> (fraction - start) / (end - start)
|
||||
else -> 1f
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
internal object QuadraticBezier {
|
||||
|
||||
private class PointEntry(
|
||||
val t: Float,
|
||||
val point: Offset
|
||||
) {
|
||||
var next: PointEntry? = null
|
||||
}
|
||||
|
||||
private fun calculate(t: Float, p0: Float, p1: Float, p2: Float): Float {
|
||||
val oneMinusT = 1 - t
|
||||
return oneMinusT * (oneMinusT * p0 + t * p1) + t * (oneMinusT * p1 + t * p2)
|
||||
}
|
||||
|
||||
private fun coordinate(t: Float, p0: Offset, p1: Offset, p2: Offset): Offset =
|
||||
Offset(
|
||||
calculate(t, p0.x, p1.x, p2.x),
|
||||
calculate(t, p0.y, p1.y, p2.y)
|
||||
)
|
||||
|
||||
fun approximate(
|
||||
p0: Offset, p1: Offset, p2: Offset,
|
||||
acceptableError: Float
|
||||
): Pair<FloatArray, LongArray> {
|
||||
val errorSquared = acceptableError * acceptableError
|
||||
|
||||
val start = PointEntry(0f, coordinate(0f, p0, p1, p2))
|
||||
var cur = start
|
||||
var next = PointEntry(1f, coordinate(1f, p0, p1, p2))
|
||||
start.next = next
|
||||
var count = 2
|
||||
while (true) {
|
||||
var needsSubdivision: Boolean
|
||||
do {
|
||||
val midT = (cur.t + next.t) / 2
|
||||
val midX = (cur.point.x + next.point.x) / 2
|
||||
val midY = (cur.point.y + next.point.y) / 2
|
||||
|
||||
val midPoint = coordinate(midT, p0, p1, p2)
|
||||
val xError = midPoint.x - midX
|
||||
val yError = midPoint.y - midY
|
||||
val midErrorSquared = (xError * xError) + (yError * yError)
|
||||
needsSubdivision = midErrorSquared > errorSquared
|
||||
|
||||
if (needsSubdivision) {
|
||||
val new = PointEntry(midT, midPoint)
|
||||
cur.next = new
|
||||
new.next = next
|
||||
next = new
|
||||
count++
|
||||
}
|
||||
} while (needsSubdivision)
|
||||
cur = next
|
||||
next = cur.next ?: break
|
||||
}
|
||||
|
||||
cur = start
|
||||
var length = 0f
|
||||
var last = Offset.Unspecified
|
||||
val result = LongArray(count)
|
||||
val lengths = FloatArray(count)
|
||||
for (i in result.indices) {
|
||||
val point = cur.point
|
||||
@Suppress("INVISIBLE_MEMBER")
|
||||
result[i] = point.packedValue
|
||||
if (i > 0) {
|
||||
val distance = (point - last).getDistance()
|
||||
length += distance
|
||||
lengths[i] = length
|
||||
}
|
||||
cur = cur.next ?: break
|
||||
last = point
|
||||
}
|
||||
|
||||
if (length > 0) {
|
||||
for (index in lengths.indices) {
|
||||
lengths[index] /= length
|
||||
}
|
||||
}
|
||||
|
||||
return lengths to result
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.round
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
@Composable
|
||||
fun SharedElement(
|
||||
key: Any,
|
||||
screenKey: Any,
|
||||
isFullscreen: Boolean = false,
|
||||
transitionSpec: SharedElementsTransitionSpec = DefaultSharedElementsTransitionSpec,
|
||||
onFractionChanged: ((Float) -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elementInfo = remember(key, screenKey, transitionSpec, onFractionChanged) {
|
||||
SharedElementInfo(key, screenKey, transitionSpec, onFractionChanged)
|
||||
}
|
||||
val realPlaceholder = placeholder ?: content
|
||||
BaseSharedElement(
|
||||
elementInfo,
|
||||
isFullscreen,
|
||||
realPlaceholder,
|
||||
{ Placeholder(it) },
|
||||
{ ElementContainer(modifier = it, content = content) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Placeholder(state: SharedElementsTransitionState) {
|
||||
with(LocalDensity.current) {
|
||||
val fraction = state.fraction
|
||||
val startBounds = state.startBounds
|
||||
val endBounds = state.endBounds
|
||||
|
||||
val fadeFraction = state.spec?.fadeProgressThresholds?.applyTo(fraction) ?: fraction
|
||||
val scaleFraction = state.spec?.scaleProgressThresholds?.applyTo(fraction) ?: fraction
|
||||
|
||||
val startScale = if (startBounds == null) Identity else
|
||||
calculateScale(startBounds, endBounds, scaleFraction)
|
||||
val offset = if (startBounds == null) IntOffset.Zero else calculateOffset(
|
||||
startBounds, endBounds,
|
||||
fraction, state.pathMotion,
|
||||
startBounds.width * startScale.scaleX
|
||||
).round()
|
||||
|
||||
@Composable
|
||||
fun Container(
|
||||
compositionLocalContext: CompositionLocalContext,
|
||||
bounds: Rect?,
|
||||
scaleX: Float,
|
||||
scaleY: Float,
|
||||
isStart: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
zIndex: Float = 0f,
|
||||
) {
|
||||
val alpha = if (bounds == null) 1f else
|
||||
calculateAlpha(state.direction, state.spec?.fadeMode, fadeFraction, isStart)
|
||||
if (alpha > 0) {
|
||||
val modifier = if (bounds == null) {
|
||||
Fullscreen.layoutId(FullscreenLayoutId)
|
||||
} else {
|
||||
Modifier.size(
|
||||
bounds.width.toDp(),
|
||||
bounds.height.toDp()
|
||||
).offset { offset }.graphicsLayer {
|
||||
this.transformOrigin = TopLeft
|
||||
this.scaleX = scaleX
|
||||
this.scaleY = scaleY
|
||||
this.alpha = alpha
|
||||
}.run {
|
||||
if (zIndex == 0f) this else zIndex(zIndex)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(compositionLocalContext) {
|
||||
ElementContainer(
|
||||
modifier = modifier,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0..1) {
|
||||
val info = if (i == 0) state.startInfo else state.endInfo ?: break
|
||||
key(info.screenKey) {
|
||||
val (scaleX, scaleY) = if (i == 0) startScale else
|
||||
calculateScale(endBounds!!, startBounds, 1 - scaleFraction)
|
||||
Container(
|
||||
compositionLocalContext = if (i == 0) {
|
||||
state.startCompositionLocalContext
|
||||
} else {
|
||||
state.endCompositionLocalContext!!
|
||||
},
|
||||
bounds = if (i == 0) startBounds else endBounds,
|
||||
scaleX = scaleX,
|
||||
scaleY = scaleY,
|
||||
isStart = i == 0,
|
||||
content = if (i == 0) state.startPlaceholder else state.endPlaceholder!!,
|
||||
zIndex = if (i == 1 && state.spec?.fadeMode == FadeMode.Out) -1f else 0f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,522 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import android.view.Choreographer
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.RecomposeScope
|
||||
import androidx.compose.runtime.currentCompositionLocalContext
|
||||
import androidx.compose.runtime.currentRecomposeScope
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastMap
|
||||
|
||||
@Composable
|
||||
internal fun BaseSharedElement(
|
||||
elementInfo: SharedElementInfo,
|
||||
isFullscreen: Boolean,
|
||||
placeholder: @Composable () -> Unit,
|
||||
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
content: @Composable (Modifier) -> Unit
|
||||
) {
|
||||
val (savedShouldHide, setShouldHide) = remember { mutableStateOf(false) }
|
||||
val rootState = LocalSharedElementsRootState.current
|
||||
val shouldHide = rootState.onElementRegistered(elementInfo)
|
||||
setShouldHide(shouldHide)
|
||||
|
||||
val compositionLocalContext = currentCompositionLocalContext
|
||||
if (isFullscreen) {
|
||||
rootState.onElementPositioned(
|
||||
elementInfo,
|
||||
compositionLocalContext,
|
||||
placeholder,
|
||||
overlay,
|
||||
null,
|
||||
setShouldHide
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
val contentModifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
rootState.onElementPositioned(
|
||||
elementInfo,
|
||||
compositionLocalContext,
|
||||
placeholder,
|
||||
overlay,
|
||||
coordinates,
|
||||
setShouldHide
|
||||
)
|
||||
}.run {
|
||||
if (shouldHide || savedShouldHide) alpha(0f) else this
|
||||
}
|
||||
|
||||
content(contentModifier)
|
||||
}
|
||||
|
||||
DisposableEffect(elementInfo) {
|
||||
onDispose {
|
||||
rootState.onElementDisposed(elementInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedElementsRoot(
|
||||
content: @Composable SharedElementsRootScope.() -> Unit
|
||||
) {
|
||||
val rootState = remember { SharedElementsRootState() }
|
||||
|
||||
Box(modifier = Modifier.onGloballyPositioned { layoutCoordinates ->
|
||||
rootState.rootCoordinates = layoutCoordinates
|
||||
rootState.rootBounds = Rect(Offset.Zero, layoutCoordinates.size.toSize())
|
||||
}) {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedElementsRootState provides rootState,
|
||||
LocalSharedElementsRootScope provides rootState.scope
|
||||
) {
|
||||
rootState.scope.content()
|
||||
UnboundedBox { SharedElementTransitionsOverlay(rootState) }
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
rootState.onDispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedElementsRootScope {
|
||||
val isRunningTransition: Boolean
|
||||
fun prepareTransition(vararg elements: Any)
|
||||
}
|
||||
|
||||
val LocalSharedElementsRootScope = staticCompositionLocalOf<SharedElementsRootScope?> { null }
|
||||
|
||||
@Composable
|
||||
private fun UnboundedBox(content: @Composable () -> Unit) {
|
||||
Layout(content) { measurables, constraints ->
|
||||
val infiniteConstraints = Constraints()
|
||||
val placeables = measurables.fastMap {
|
||||
val isFullscreen = it.layoutId === FullscreenLayoutId
|
||||
it.measure(if (isFullscreen) constraints else infiniteConstraints)
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeables.fastForEach { it.place(0, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedElementTransitionsOverlay(rootState: SharedElementsRootState) {
|
||||
rootState.recomposeScope = currentRecomposeScope
|
||||
rootState.trackers.forEach { (key, tracker) ->
|
||||
key(key) {
|
||||
val transition = tracker.transition
|
||||
val start = (tracker.state as? SharedElementsTracker.State.StartElementPositioned)?.startElement
|
||||
if (transition != null || (start != null && start.bounds == null)) {
|
||||
val startElement = start ?: transition!!.startElement
|
||||
val startScreenKey = startElement.info.screenKey
|
||||
val endElement = (transition as? SharedElementTransition.InProgress)?.endElement
|
||||
val spec = startElement.info.spec
|
||||
val animated = remember(startScreenKey) { Animatable(0f) }
|
||||
val fraction = animated.value
|
||||
startElement.info.onFractionChanged?.invoke(fraction)
|
||||
endElement?.info?.onFractionChanged?.invoke(1 - fraction)
|
||||
|
||||
val direction = if (endElement == null) null else remember(startScreenKey) {
|
||||
val direction = spec.direction
|
||||
if (direction != TransitionDirection.Auto) direction else
|
||||
calculateDirection(
|
||||
startElement.bounds ?: rootState.rootBounds!!,
|
||||
endElement.bounds ?: rootState.rootBounds!!
|
||||
)
|
||||
}
|
||||
|
||||
startElement.Placeholder(
|
||||
rootState, fraction, endElement,
|
||||
direction, spec, tracker.pathMotion
|
||||
)
|
||||
|
||||
if (transition is SharedElementTransition.InProgress) {
|
||||
LaunchedEffect(transition, animated) {
|
||||
repeat(spec.waitForFrames) { withFrameNanos {} }
|
||||
animated.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = spec.durationMillis,
|
||||
delayMillis = spec.delayMillis,
|
||||
easing = spec.easing
|
||||
)
|
||||
)
|
||||
transition.onTransitionFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionedSharedElement.Placeholder(
|
||||
rootState: SharedElementsRootState,
|
||||
fraction: Float,
|
||||
end: PositionedSharedElement? = null,
|
||||
direction: TransitionDirection? = null,
|
||||
spec: SharedElementsTransitionSpec? = null,
|
||||
pathMotion: PathMotion? = null
|
||||
) {
|
||||
overlay(
|
||||
SharedElementsTransitionState(
|
||||
fraction = fraction,
|
||||
startInfo = info,
|
||||
startBounds = if (end == null) bounds else bounds ?: rootState.rootBounds,
|
||||
startCompositionLocalContext = compositionLocalContext,
|
||||
startPlaceholder = placeholder,
|
||||
endInfo = end?.info,
|
||||
endBounds = end?.run { bounds ?: rootState.rootBounds },
|
||||
endCompositionLocalContext = end?.compositionLocalContext,
|
||||
endPlaceholder = end?.placeholder,
|
||||
direction = direction,
|
||||
spec = spec,
|
||||
pathMotion = pathMotion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private val LocalSharedElementsRootState = staticCompositionLocalOf<SharedElementsRootState> {
|
||||
error("SharedElementsRoot not found. SharedElement must be hosted in SharedElementsRoot.")
|
||||
}
|
||||
|
||||
private class SharedElementsRootState {
|
||||
private val choreographer = ChoreographerWrapper()
|
||||
val scope: SharedElementsRootScope = Scope()
|
||||
var trackers by mutableStateOf(mapOf<Any, SharedElementsTracker>())
|
||||
var recomposeScope: RecomposeScope? = null
|
||||
var rootCoordinates: LayoutCoordinates? = null
|
||||
var rootBounds: Rect? = null
|
||||
|
||||
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
choreographer.removeCallback(elementInfo)
|
||||
return getTracker(elementInfo).onElementRegistered(elementInfo)
|
||||
}
|
||||
|
||||
fun onElementPositioned(
|
||||
elementInfo: SharedElementInfo,
|
||||
compositionLocalContext: CompositionLocalContext,
|
||||
placeholder: @Composable () -> Unit,
|
||||
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
coordinates: LayoutCoordinates?,
|
||||
setShouldHide: (Boolean) -> Unit
|
||||
) {
|
||||
val element = PositionedSharedElement(
|
||||
info = elementInfo,
|
||||
compositionLocalContext = compositionLocalContext,
|
||||
placeholder = placeholder,
|
||||
overlay = overlay,
|
||||
bounds = coordinates?.calculateBoundsInRoot()
|
||||
)
|
||||
getTracker(elementInfo).onElementPositioned(element, setShouldHide)
|
||||
}
|
||||
|
||||
fun onElementDisposed(elementInfo: SharedElementInfo) {
|
||||
choreographer.postCallback(elementInfo) {
|
||||
val tracker = getTracker(elementInfo)
|
||||
tracker.onElementUnregistered(elementInfo)
|
||||
if (tracker.isEmpty) trackers = trackers - elementInfo.key
|
||||
}
|
||||
}
|
||||
|
||||
fun onDispose() {
|
||||
choreographer.clear()
|
||||
}
|
||||
|
||||
private fun getTracker(elementInfo: SharedElementInfo): SharedElementsTracker {
|
||||
return trackers[elementInfo.key] ?: SharedElementsTracker { transition ->
|
||||
recomposeScope?.invalidate()
|
||||
(scope as Scope).isRunningTransition = if (transition != null) true else
|
||||
trackers.values.any { it.transition != null }
|
||||
}.also { trackers = trackers + (elementInfo.key to it) }
|
||||
}
|
||||
|
||||
private fun LayoutCoordinates.calculateBoundsInRoot(): Rect =
|
||||
Rect(
|
||||
rootCoordinates?.localPositionOf(this, Offset.Zero)
|
||||
?: positionInRoot(), size.toSize()
|
||||
)
|
||||
|
||||
private inner class Scope : SharedElementsRootScope {
|
||||
|
||||
override var isRunningTransition: Boolean by mutableStateOf(false)
|
||||
|
||||
override fun prepareTransition(vararg elements: Any) {
|
||||
elements.forEach {
|
||||
trackers[it]?.prepareTransition()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class SharedElementsTracker(
|
||||
private val onTransitionChanged: (SharedElementTransition?) -> Unit
|
||||
) {
|
||||
var state: State = State.Empty
|
||||
|
||||
var pathMotion: PathMotion? = null
|
||||
|
||||
// Use snapshot state to trigger recomposition of start element when transition starts
|
||||
private var _transition: SharedElementTransition? by mutableStateOf(null)
|
||||
var transition: SharedElementTransition?
|
||||
get() = _transition
|
||||
set(value) {
|
||||
if (_transition != value) {
|
||||
_transition = value
|
||||
if (value == null) pathMotion = null
|
||||
onTransitionChanged(value)
|
||||
}
|
||||
}
|
||||
|
||||
val isEmpty: Boolean get() = state is State.Empty
|
||||
|
||||
private fun State.StartElementPositioned.prepareTransition() {
|
||||
if (transition !is SharedElementTransition.WaitingForEndElementPosition) {
|
||||
transition = SharedElementTransition.WaitingForEndElementPosition(startElement)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareTransition() {
|
||||
(state as? State.StartElementPositioned)?.prepareTransition()
|
||||
}
|
||||
|
||||
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
var shouldHide = false
|
||||
|
||||
val transition = transition
|
||||
if (transition is SharedElementTransition.InProgress
|
||||
&& elementInfo != transition.startElement.info
|
||||
&& elementInfo != transition.endElement.info
|
||||
) {
|
||||
state = State.StartElementPositioned(startElement = transition.endElement)
|
||||
this.transition = null
|
||||
}
|
||||
|
||||
when (val state = state) {
|
||||
is State.StartElementPositioned -> {
|
||||
if (!state.isRegistered(elementInfo)) {
|
||||
shouldHide = true
|
||||
this.state = State.EndElementRegistered(
|
||||
startElement = state.startElement,
|
||||
endElementInfo = elementInfo
|
||||
)
|
||||
state.prepareTransition()
|
||||
}
|
||||
}
|
||||
is State.StartElementRegistered -> {
|
||||
if (elementInfo != state.startElementInfo) {
|
||||
this.state = State.StartElementRegistered(startElementInfo = elementInfo)
|
||||
}
|
||||
}
|
||||
is State.Empty -> {
|
||||
this.state = State.StartElementRegistered(startElementInfo = elementInfo)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return shouldHide || transition != null
|
||||
}
|
||||
|
||||
fun onElementPositioned(element: PositionedSharedElement, setShouldHide: (Boolean) -> Unit) {
|
||||
val state = state
|
||||
if (state is State.StartElementPositioned && element.info == state.startElementInfo) {
|
||||
state.startElement = element
|
||||
return
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is State.EndElementRegistered -> {
|
||||
if (element.info == state.endElementInfo) {
|
||||
this.state = State.InTransition
|
||||
val spec = element.info.spec
|
||||
this.pathMotion = spec.pathMotionFactory()
|
||||
transition = SharedElementTransition.InProgress(
|
||||
startElement = state.startElement,
|
||||
endElement = element,
|
||||
onTransitionFinished = {
|
||||
this.state = State.StartElementPositioned(startElement = element)
|
||||
transition = null
|
||||
setShouldHide(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is State.StartElementRegistered -> {
|
||||
if (element.info == state.startElementInfo) {
|
||||
this.state = State.StartElementPositioned(startElement = element)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun onElementUnregistered(elementInfo: SharedElementInfo) {
|
||||
when (val state = state) {
|
||||
is State.EndElementRegistered -> {
|
||||
if (elementInfo == state.endElementInfo) {
|
||||
this.state = State.StartElementPositioned(startElement = state.startElement)
|
||||
transition = null
|
||||
} else if (elementInfo == state.startElement.info) {
|
||||
this.state =
|
||||
State.StartElementRegistered(startElementInfo = state.endElementInfo)
|
||||
transition = null
|
||||
}
|
||||
}
|
||||
is State.StartElementRegistered -> {
|
||||
if (elementInfo == state.startElementInfo) {
|
||||
this.state = State.Empty
|
||||
transition = null
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
object Empty : State()
|
||||
|
||||
open class StartElementRegistered(val startElementInfo: SharedElementInfo) : State() {
|
||||
open fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
return elementInfo == startElementInfo
|
||||
}
|
||||
}
|
||||
|
||||
open class StartElementPositioned(var startElement: PositionedSharedElement) :
|
||||
StartElementRegistered(startElement.info)
|
||||
|
||||
class EndElementRegistered(
|
||||
startElement: PositionedSharedElement,
|
||||
val endElementInfo: SharedElementInfo
|
||||
) : StartElementPositioned(startElement) {
|
||||
override fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
return super.isRegistered(elementInfo) || elementInfo == endElementInfo
|
||||
}
|
||||
}
|
||||
|
||||
object InTransition : State()
|
||||
}
|
||||
}
|
||||
|
||||
enum class TransitionDirection {
|
||||
Auto, Enter, Return
|
||||
}
|
||||
|
||||
enum class FadeMode {
|
||||
In, Out, Cross, Through
|
||||
}
|
||||
|
||||
const val FadeThroughProgressThreshold = 0.35f
|
||||
|
||||
internal class SharedElementsTransitionState(
|
||||
val fraction: Float,
|
||||
val startInfo: SharedElementInfo,
|
||||
val startBounds: Rect?,
|
||||
val startCompositionLocalContext: CompositionLocalContext,
|
||||
val startPlaceholder: @Composable () -> Unit,
|
||||
val endInfo: SharedElementInfo?,
|
||||
val endBounds: Rect?,
|
||||
val endCompositionLocalContext: CompositionLocalContext?,
|
||||
val endPlaceholder: (@Composable () -> Unit)?,
|
||||
val direction: TransitionDirection?,
|
||||
val spec: SharedElementsTransitionSpec?,
|
||||
val pathMotion: PathMotion?
|
||||
)
|
||||
|
||||
internal val TopLeft = TransformOrigin(0f, 0f)
|
||||
|
||||
internal open class SharedElementInfo(
|
||||
val key: Any,
|
||||
val screenKey: Any,
|
||||
val spec: SharedElementsTransitionSpec,
|
||||
val onFractionChanged: ((Float) -> Unit)?
|
||||
) {
|
||||
|
||||
final override fun equals(other: Any?): Boolean =
|
||||
other is SharedElementInfo && other.key == key && other.screenKey == screenKey
|
||||
|
||||
final override fun hashCode(): Int = 31 * key.hashCode() + screenKey.hashCode()
|
||||
|
||||
}
|
||||
|
||||
private class PositionedSharedElement(
|
||||
val info: SharedElementInfo,
|
||||
val compositionLocalContext: CompositionLocalContext,
|
||||
val placeholder: @Composable () -> Unit,
|
||||
val overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
val bounds: Rect?
|
||||
)
|
||||
|
||||
private sealed class SharedElementTransition(val startElement: PositionedSharedElement) {
|
||||
|
||||
class WaitingForEndElementPosition(startElement: PositionedSharedElement) :
|
||||
SharedElementTransition(startElement)
|
||||
|
||||
class InProgress(
|
||||
startElement: PositionedSharedElement,
|
||||
val endElement: PositionedSharedElement,
|
||||
val onTransitionFinished: () -> Unit
|
||||
) : SharedElementTransition(startElement)
|
||||
|
||||
}
|
||||
|
||||
private class ChoreographerWrapper {
|
||||
private val callbacks = mutableMapOf<SharedElementInfo, Choreographer.FrameCallback>()
|
||||
private val choreographer = Choreographer.getInstance()
|
||||
|
||||
fun postCallback(elementInfo: SharedElementInfo, callback: () -> Unit) {
|
||||
if (callbacks.containsKey(elementInfo)) return
|
||||
|
||||
val frameCallback = Choreographer.FrameCallback {
|
||||
callbacks.remove(elementInfo)
|
||||
callback()
|
||||
}
|
||||
callbacks[elementInfo] = frameCallback
|
||||
choreographer.postFrameCallback(frameCallback)
|
||||
}
|
||||
|
||||
fun removeCallback(elementInfo: SharedElementInfo) {
|
||||
callbacks.remove(elementInfo)?.also(choreographer::removeFrameCallback)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
callbacks.values.forEach(choreographer::removeFrameCallback)
|
||||
callbacks.clear()
|
||||
}
|
||||
}
|
||||
|
||||
internal val Fullscreen = Modifier.fillMaxSize()
|
||||
internal val FullscreenLayoutId = Any()
|
||||
@ -0,0 +1,23 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.animation.core.AnimationConstants
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
|
||||
open class SharedElementsTransitionSpec(
|
||||
val pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||
/**
|
||||
* Frames to wait for before starting transition. Useful when the frame skip caused by
|
||||
* rendering the new screen makes the animation not smooth.
|
||||
*/
|
||||
val waitForFrames: Int = 1,
|
||||
val durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||
val delayMillis: Int = 0,
|
||||
val easing: Easing = FastOutSlowInEasing,
|
||||
val direction: TransitionDirection = TransitionDirection.Auto,
|
||||
val fadeMode: FadeMode = FadeMode.Cross,
|
||||
val fadeProgressThresholds: ProgressThresholds? = null,
|
||||
val scaleProgressThresholds: ProgressThresholds? = null
|
||||
)
|
||||
|
||||
val DefaultSharedElementsTransitionSpec = SharedElementsTransitionSpec()
|
||||
@ -0,0 +1,500 @@
|
||||
package org.xtimms.tokusho.core.motion.sharedelements
|
||||
|
||||
import androidx.compose.animation.core.AnimationConstants
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.material.LocalAbsoluteElevation
|
||||
import androidx.compose.material.LocalElevationOverlay
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.round
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedMaterialContainer(
|
||||
key: Any,
|
||||
screenKey: Any,
|
||||
isFullscreen: Boolean = false,
|
||||
shape: Shape = RectangleShape,
|
||||
color: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(color),
|
||||
border: BorderStroke? = null,
|
||||
elevation: Dp = 0.dp,
|
||||
transitionSpec: MaterialContainerTransformSpec = DefaultMaterialContainerTransformSpec,
|
||||
onFractionChanged: ((Float) -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elementInfo = MaterialContainerInfo(
|
||||
key, screenKey, shape, color, contentColor,
|
||||
border, elevation, transitionSpec, onFractionChanged
|
||||
)
|
||||
val realPlaceholder = placeholder ?: content
|
||||
BaseSharedElement(
|
||||
elementInfo,
|
||||
isFullscreen,
|
||||
realPlaceholder,
|
||||
{ Placeholder(it) },
|
||||
{
|
||||
MaterialContainer(
|
||||
modifier = it,
|
||||
shape = shape,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
border = border,
|
||||
elevation = elevation,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaterialContainer(
|
||||
modifier: Modifier,
|
||||
shape: Shape,
|
||||
color: Color,
|
||||
contentColor: Color,
|
||||
border: BorderStroke?,
|
||||
elevation: Dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elevationOverlay = LocalElevationOverlay.current
|
||||
val absoluteElevation = LocalAbsoluteElevation.current + elevation
|
||||
val backgroundColor = if (color == MaterialTheme.colorScheme.surface && elevationOverlay != null) {
|
||||
elevationOverlay.apply(color, absoluteElevation)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalAbsoluteElevation provides absoluteElevation
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.shadow(elevation, shape, clip = false)
|
||||
.then(if (border != null) Modifier.border(border, shape) else Modifier)
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape),
|
||||
propagateMinConstraints = true
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Placeholder(state: SharedElementsTransitionState) {
|
||||
with(LocalDensity.current) {
|
||||
val startInfo = state.startInfo as MaterialContainerInfo
|
||||
val direction = state.direction
|
||||
val spec = state.spec as? MaterialContainerTransformSpec
|
||||
val start = state.startBounds
|
||||
val end = state.endBounds
|
||||
val fraction = state.fraction
|
||||
|
||||
val surfaceModifier: Modifier
|
||||
var startContentModifier = Fullscreen
|
||||
val elements = mutableListOf<ElementCall>()
|
||||
|
||||
var shape = startInfo.shape
|
||||
var color = startInfo.color
|
||||
var contentColor = startInfo.contentColor
|
||||
var border = startInfo.border
|
||||
var elevation = startInfo.elevation
|
||||
var startAlpha = 1f
|
||||
|
||||
if (start == null) {
|
||||
surfaceModifier = Modifier.layoutId(FullscreenLayoutId)
|
||||
} else {
|
||||
val fitMode = if (spec == null || end == null) null else remember {
|
||||
val mode = spec.fitMode
|
||||
if (mode != FitMode.Auto) mode else
|
||||
calculateFitMode(direction == TransitionDirection.Enter, start, end)
|
||||
}
|
||||
|
||||
val thresholds =
|
||||
if (spec == null || direction == null) DefaultEnterThresholds else remember {
|
||||
spec.progressThresholdsGroupFor(direction, state.pathMotion!!)
|
||||
}
|
||||
|
||||
val scaleFraction = thresholds.scale.applyTo(fraction)
|
||||
val scale = calculateScale(start, end, scaleFraction)
|
||||
val contentScale = if (fitMode == FitMode.Height) scale.scaleY else scale.scaleX
|
||||
val scaleMaskFraction = thresholds.scaleMask.applyTo(fraction)
|
||||
val (containerWidth, containerHeight) = if (end == null) start.size * contentScale else {
|
||||
if (fitMode == FitMode.Height) Size(
|
||||
width = lerp(
|
||||
start.width * contentScale,
|
||||
start.height * contentScale / end.height * end.width,
|
||||
scaleMaskFraction
|
||||
),
|
||||
height = start.height * contentScale
|
||||
) else Size(
|
||||
width = start.width * contentScale,
|
||||
height = lerp(
|
||||
start.height * contentScale,
|
||||
start.width * contentScale / end.width * end.height,
|
||||
scaleMaskFraction
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val offset =
|
||||
calculateOffset(start, end, fraction, state.pathMotion, containerWidth).round()
|
||||
|
||||
surfaceModifier = Modifier
|
||||
.size(
|
||||
containerWidth.toDp(),
|
||||
containerHeight.toDp()
|
||||
)
|
||||
.offset { offset }
|
||||
|
||||
val endInfo = state.endInfo as? MaterialContainerInfo
|
||||
val fadeFraction = thresholds.fade.applyTo(fraction)
|
||||
if (end != null && endInfo != null) {
|
||||
val endAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, false)
|
||||
if (endAlpha > 0) {
|
||||
val endScale = calculateScale(end, start, 1 - scaleFraction).run {
|
||||
if (fitMode == FitMode.Height) scaleY else scaleX
|
||||
}
|
||||
val containerColor = spec?.endContainerColor ?: Color.Transparent
|
||||
val containerModifier = Modifier.fillMaxSize().run {
|
||||
if (containerColor == Color.Transparent) this else
|
||||
background(containerColor.copy(alpha = containerColor.alpha * endAlpha))
|
||||
}.run {
|
||||
if (state.spec?.fadeMode != FadeMode.Out) zIndex(1f) else this
|
||||
}
|
||||
val contentModifier = Modifier
|
||||
.size(
|
||||
end.width.toDp(),
|
||||
end.height.toDp()
|
||||
)
|
||||
.run {
|
||||
if (fitMode == FitMode.Height) offset {
|
||||
IntOffset(
|
||||
((containerWidth - end.width * endScale) / 2).roundToInt(),
|
||||
0
|
||||
)
|
||||
} else this
|
||||
}
|
||||
.graphicsLayer {
|
||||
this.transformOrigin = TopLeft
|
||||
this.scaleX = endScale
|
||||
this.scaleY = endScale
|
||||
this.alpha = endAlpha
|
||||
}
|
||||
|
||||
elements.add(
|
||||
ElementCall(
|
||||
endInfo.screenKey,
|
||||
containerModifier,
|
||||
true,
|
||||
contentModifier,
|
||||
state.endCompositionLocalContext!!,
|
||||
state.endPlaceholder!!
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val shapeFraction = thresholds.shapeMask.applyTo(fraction)
|
||||
shape = lerp(startInfo.shape, endInfo.shape, shapeFraction)
|
||||
color = lerp(startInfo.color, endInfo.color, shapeFraction)
|
||||
contentColor = lerp(startInfo.contentColor, endInfo.contentColor, shapeFraction)
|
||||
border = (startInfo.border ?: endInfo.border)?.copy(
|
||||
width = lerp(
|
||||
startInfo.border?.width ?: 0.dp,
|
||||
endInfo.border?.width ?: 0.dp,
|
||||
shapeFraction
|
||||
)
|
||||
)
|
||||
elevation = lerp(startInfo.elevation, endInfo.elevation, shapeFraction)
|
||||
}
|
||||
|
||||
startAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, true)
|
||||
if (startAlpha > 0) {
|
||||
startContentModifier = Modifier
|
||||
.size(
|
||||
start.width.toDp(),
|
||||
start.height.toDp()
|
||||
)
|
||||
.run {
|
||||
if (fitMode == FitMode.Height) offset {
|
||||
IntOffset(
|
||||
((containerWidth - start.width * contentScale) / 2).roundToInt(),
|
||||
0
|
||||
)
|
||||
} else this
|
||||
}
|
||||
.graphicsLayer {
|
||||
this.transformOrigin = TopLeft
|
||||
this.scaleX = contentScale
|
||||
this.scaleY = contentScale
|
||||
this.alpha = startAlpha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startAlpha > 0) {
|
||||
val containerColor = spec?.startContainerColor ?: Color.Transparent
|
||||
val containerModifier = Modifier.fillMaxSize().run {
|
||||
if (containerColor == Color.Transparent) this else
|
||||
background(containerColor.copy(alpha = containerColor.alpha * startAlpha))
|
||||
}
|
||||
|
||||
elements.add(
|
||||
ElementCall(
|
||||
startInfo.screenKey,
|
||||
containerModifier,
|
||||
start != null,
|
||||
startContentModifier,
|
||||
state.startCompositionLocalContext,
|
||||
state.startPlaceholder
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialContainer(
|
||||
modifier = surfaceModifier,
|
||||
shape = shape,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
border = border,
|
||||
elevation = elevation
|
||||
) {
|
||||
Box {
|
||||
elements.forEach { call ->
|
||||
key(call.screenKey) {
|
||||
ElementContainer(
|
||||
modifier = call.containerModifier,
|
||||
relaxMaxSize = call.relaxMaxSize
|
||||
) {
|
||||
ElementContainer(modifier = call.contentModifier) {
|
||||
CompositionLocalProvider(
|
||||
call.compositionLocalContext,
|
||||
content = call.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ElementCall(
|
||||
val screenKey: Any,
|
||||
val containerModifier: Modifier,
|
||||
val relaxMaxSize: Boolean,
|
||||
val contentModifier: Modifier,
|
||||
val compositionLocalContext: CompositionLocalContext,
|
||||
val content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
private fun calculateFitMode(entering: Boolean, start: Rect, end: Rect): FitMode {
|
||||
val startWidth = start.width
|
||||
val startHeight = start.height
|
||||
val endWidth = end.width
|
||||
val endHeight = end.height
|
||||
|
||||
val endHeightFitToWidth = endHeight * startWidth / endWidth
|
||||
val startHeightFitToWidth = startHeight * endWidth / startWidth
|
||||
val fitWidth = if (entering)
|
||||
endHeightFitToWidth >= startHeight else startHeightFitToWidth >= endHeight
|
||||
return if (fitWidth) FitMode.Width else FitMode.Height
|
||||
}
|
||||
|
||||
private fun lerp(start: Shape, end: Shape, fraction: Float): Shape {
|
||||
if ((start == RectangleShape && end == RectangleShape) ||
|
||||
(start != RectangleShape && start !is CornerBasedShape) ||
|
||||
(end != RectangleShape && end !is CornerBasedShape)
|
||||
) return start
|
||||
val topStart = lerp(
|
||||
(start as? CornerBasedShape)?.topStart,
|
||||
(end as? CornerBasedShape)?.topStart,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
val topEnd = lerp(
|
||||
(start as? CornerBasedShape)?.topEnd,
|
||||
(end as? CornerBasedShape)?.topEnd,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
val bottomEnd = lerp(
|
||||
(start as? CornerBasedShape)?.bottomEnd,
|
||||
(end as? CornerBasedShape)?.bottomEnd,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
val bottomStart = lerp(
|
||||
(start as? CornerBasedShape)?.bottomStart,
|
||||
(end as? CornerBasedShape)?.bottomStart,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
return when {
|
||||
start is RoundedCornerShape || (start == RectangleShape && end is RoundedCornerShape) ->
|
||||
RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
start is CutCornerShape || (start == RectangleShape && end is CutCornerShape) ->
|
||||
CutCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
else -> start
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: CornerSize?, end: CornerSize?, fraction: Float): CornerSize? {
|
||||
if (start == null && end == null) return null
|
||||
return object : CornerSize {
|
||||
override fun toPx(shapeSize: Size, density: Density): Float =
|
||||
lerp(
|
||||
start?.toPx(shapeSize, density) ?: 0f,
|
||||
end?.toPx(shapeSize, density) ?: 0f,
|
||||
fraction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class MaterialContainerInfo(
|
||||
key: Any,
|
||||
screenKey: Any,
|
||||
val shape: Shape,
|
||||
val color: Color,
|
||||
val contentColor: Color,
|
||||
val border: BorderStroke?,
|
||||
val elevation: Dp,
|
||||
spec: SharedElementsTransitionSpec,
|
||||
onFractionChanged: ((Float) -> Unit)?,
|
||||
) : SharedElementInfo(key, screenKey, spec, onFractionChanged)
|
||||
|
||||
enum class FitMode {
|
||||
Auto, Width, Height
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private class ProgressThresholdsGroup(
|
||||
val fade: ProgressThresholds,
|
||||
val scale: ProgressThresholds,
|
||||
val scaleMask: ProgressThresholds,
|
||||
val shapeMask: ProgressThresholds
|
||||
)
|
||||
|
||||
// Default animation thresholds. Will be used by default when the default linear PathMotion is
|
||||
// being used or when no other progress thresholds are appropriate (e.g., the arc thresholds for
|
||||
// an arc path).
|
||||
private val DefaultEnterThresholds = ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0f, 0.25f),
|
||||
scale = ProgressThresholds(0f, 1f),
|
||||
scaleMask = ProgressThresholds(0f, 1f),
|
||||
shapeMask = ProgressThresholds(0f, 0.75f)
|
||||
)
|
||||
private val DefaultReturnThresholds = ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.60f, 0.90f),
|
||||
scale = ProgressThresholds(0f, 1f),
|
||||
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||
shapeMask = ProgressThresholds(0.30f, 0.90f)
|
||||
)
|
||||
|
||||
// Default animation thresholds for an arc path. Will be used by default when the PathMotion is
|
||||
// set to MaterialArcMotion.
|
||||
private val DefaultEnterThresholdsArc = ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.10f, 0.40f),
|
||||
scale = ProgressThresholds(0.10f, 1f),
|
||||
scaleMask = ProgressThresholds(0.10f, 1f),
|
||||
shapeMask = ProgressThresholds(0.10f, 0.90f)
|
||||
)
|
||||
private val DefaultReturnThresholdsArc = ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.60f, 0.90f),
|
||||
scale = ProgressThresholds(0f, 0.90f),
|
||||
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||
shapeMask = ProgressThresholds(0.20f, 0.90f)
|
||||
)
|
||||
|
||||
class MaterialContainerTransformSpec(
|
||||
pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||
/**
|
||||
* Frames to wait for before starting transition. Useful when the frame skip caused by
|
||||
* rendering the new screen makes the animation not smooth.
|
||||
*/
|
||||
waitForFrames: Int = 1,
|
||||
durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||
delayMillis: Int = 0,
|
||||
easing: Easing = FastOutSlowInEasing,
|
||||
direction: TransitionDirection = TransitionDirection.Auto,
|
||||
fadeMode: FadeMode = FadeMode.In,
|
||||
val fitMode: FitMode = FitMode.Auto,
|
||||
val startContainerColor: Color = Color.Transparent,
|
||||
val endContainerColor: Color = Color.Transparent,
|
||||
fadeProgressThresholds: ProgressThresholds? = null,
|
||||
scaleProgressThresholds: ProgressThresholds? = null,
|
||||
val scaleMaskProgressThresholds: ProgressThresholds? = null,
|
||||
val shapeMaskProgressThresholds: ProgressThresholds? = null
|
||||
) : SharedElementsTransitionSpec(
|
||||
pathMotionFactory,
|
||||
waitForFrames,
|
||||
durationMillis,
|
||||
delayMillis,
|
||||
easing,
|
||||
direction,
|
||||
fadeMode,
|
||||
fadeProgressThresholds,
|
||||
scaleProgressThresholds
|
||||
)
|
||||
|
||||
val DefaultMaterialContainerTransformSpec = MaterialContainerTransformSpec()
|
||||
|
||||
private fun MaterialContainerTransformSpec.progressThresholdsGroupFor(
|
||||
direction: TransitionDirection,
|
||||
pathMotion: PathMotion
|
||||
): ProgressThresholdsGroup {
|
||||
val default = if (pathMotion is MaterialArcMotion) {
|
||||
if (direction == TransitionDirection.Enter)
|
||||
DefaultEnterThresholdsArc else DefaultReturnThresholdsArc
|
||||
} else {
|
||||
if (direction == TransitionDirection.Enter)
|
||||
DefaultEnterThresholds else DefaultReturnThresholds
|
||||
}
|
||||
return ProgressThresholdsGroup(
|
||||
fadeProgressThresholds ?: default.fade,
|
||||
scaleProgressThresholds ?: default.scale,
|
||||
scaleMaskProgressThresholds ?: default.scaleMask,
|
||||
shapeMaskProgressThresholds ?: default.shapeMask
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package org.xtimms.tokusho.core.network.interceptors
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.tokusho.BuildConfig
|
||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||
import org.xtimms.tokusho.utils.system.ensureSuccess
|
||||
import org.xtimms.tokusho.utils.system.isHttpOrHttps
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageProxyInterceptor @Inject constructor() : Interceptor {
|
||||
|
||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val request = chain.request
|
||||
if (!AppSettings.isImagesProxyEnabled()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val url: HttpUrl? = when (val data = request.data) {
|
||||
is HttpUrl -> data
|
||||
is String -> data.toHttpUrlOrNull()
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val newUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", url.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val size = request.sizeResolver.size()
|
||||
if (!size.isOriginal) {
|
||||
newUrl.addQueryParameter("crop", "cover")
|
||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||
}
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.data(newUrl.build())
|
||||
.build()
|
||||
val result = chain.proceed(newRequest)
|
||||
return if (result is SuccessResult) {
|
||||
result
|
||||
} else {
|
||||
logDebug((result as? ErrorResult)?.throwable)
|
||||
chain.proceed(request).also {
|
||||
if (it is SuccessResult) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||
if (!AppSettings.isImagesProxyEnabled()) {
|
||||
return okHttp.newCall(request).await()
|
||||
}
|
||||
val sourceUrl = request.url
|
||||
val targetUrl = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("wsrv.nl")
|
||||
.addQueryParameter("url", sourceUrl.toString())
|
||||
.addQueryParameter("we", null)
|
||||
val newRequest = request.newBuilder()
|
||||
.url(targetUrl.build())
|
||||
.build()
|
||||
return runCatchingCancellable {
|
||||
okHttp.doCall(newRequest)
|
||||
}.recover {
|
||||
logDebug(it)
|
||||
okHttp.doCall(request).also {
|
||||
blacklist.add(sourceUrl.host)
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||
return newCall(request).await().ensureSuccess()
|
||||
}
|
||||
|
||||
private fun logDebug(e: Throwable?) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w("ImageProxy", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package org.xtimms.tokusho.sections.history
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.ImageLoader
|
||||
import org.xtimms.tokusho.core.components.MangaCover
|
||||
|
||||
@Composable
|
||||
fun HistoryItem(
|
||||
coil: ImageLoader,
|
||||
history: HistoryItemModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 8.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MangaCover.Book(
|
||||
coil = coil,
|
||||
modifier = Modifier.height(96.dp),
|
||||
data = history.manga.coverUrl,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(start = 16.dp, end = 4.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = history.manga.title,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (!history.manga.author.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = history.manga.author.let { it.orEmpty() },
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = history.manga.tags.joinToString(separator = ", ") { it.title },
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package org.xtimms.tokusho.sections.history
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.model.ListModel
|
||||
import org.xtimms.tokusho.core.model.MangaHistory
|
||||
|
||||
data class HistoryItemModel(
|
||||
val manga: Manga,
|
||||
val history: MangaHistory,
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is HistoryItemModel && other.manga.id == manga.id
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package org.xtimms.tokusho.sections.history
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
|
||||
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||
import org.xtimms.tokusho.utils.lang.mapItems
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
) : KotatsuBaseViewModel() {
|
||||
|
||||
private val historyStateFlow = repository.observeAllWithHistory()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val content = historyStateFlow
|
||||
.filterNotNull()
|
||||
.mapItems { HistoryItemModel(it.manga, it.history) }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun removeFromHistory(history: HistoryItemModel) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.delete(history.manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,405 @@
|
||||
package org.xtimms.tokusho.sections.history
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.DeleteForever
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.unit.min
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.ui.theme.SEED
|
||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||
|
||||
data class SwipeActionsConfig(
|
||||
val threshold: Float,
|
||||
val icon: ImageVector?,
|
||||
val iconTint: Color,
|
||||
val background: Color,
|
||||
val backgroundActive: Color,
|
||||
val stayDismissed: Boolean,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
val DefaultSwipeActionsConfig = SwipeActionsConfig(
|
||||
threshold = 0.4f,
|
||||
icon = null,
|
||||
iconTint = Color.Transparent,
|
||||
background = Color.Transparent,
|
||||
backgroundActive = Color.Transparent,
|
||||
stayDismissed = false,
|
||||
onDismiss = {},
|
||||
)
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterialApi::class,
|
||||
ExperimentalComposeUiApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun SwipeActions(
|
||||
modifier: Modifier = Modifier,
|
||||
startActionsConfig: SwipeActionsConfig = DefaultSwipeActionsConfig,
|
||||
endActionsConfig: SwipeActionsConfig = DefaultSwipeActionsConfig,
|
||||
onTried: () -> Unit = {},
|
||||
showTutorial: Boolean = false,
|
||||
content: @Composable (DismissState) -> Unit,
|
||||
) = BoxWithConstraints(modifier) {
|
||||
val width = constraints.maxWidth.toFloat()
|
||||
val height = constraints.maxHeight.toFloat()
|
||||
|
||||
var willDismissDirection: DismissDirection? by remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val state = rememberDismissState(
|
||||
confirmStateChange = {
|
||||
onTried()
|
||||
if (willDismissDirection == DismissDirection.StartToEnd
|
||||
&& it == DismissValue.DismissedToEnd
|
||||
) {
|
||||
startActionsConfig.onDismiss()
|
||||
startActionsConfig.stayDismissed
|
||||
} else if (willDismissDirection == DismissDirection.EndToStart &&
|
||||
it == DismissValue.DismissedToStart
|
||||
) {
|
||||
endActionsConfig.onDismiss()
|
||||
endActionsConfig.stayDismissed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var showingTutorial by remember {
|
||||
mutableStateOf(showTutorial)
|
||||
}
|
||||
|
||||
if (showingTutorial) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val x by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = width * (startActionsConfig.threshold) / 2f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(500, easing = FastOutSlowInEasing, delayMillis = 1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
val dir by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(4000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
|
||||
LaunchedEffect(key1 = x, block = {
|
||||
state.performDrag(x * (if (dir > 0.5f) 1f else -1f) - state.offset.value)
|
||||
})
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit, block = {
|
||||
snapshotFlow { state.offset.value }
|
||||
.collect {
|
||||
willDismissDirection = when {
|
||||
it > width * startActionsConfig.threshold -> DismissDirection.StartToEnd
|
||||
it < -width * endActionsConfig.threshold -> DismissDirection.EndToStart
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
LaunchedEffect(key1 = willDismissDirection, block = {
|
||||
if (willDismissDirection != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
})
|
||||
|
||||
val dismissDirections by remember(startActionsConfig, endActionsConfig) {
|
||||
derivedStateOf {
|
||||
mutableSetOf<DismissDirection>().apply {
|
||||
if (startActionsConfig != DefaultSwipeActionsConfig) add(DismissDirection.StartToEnd)
|
||||
if (endActionsConfig != DefaultSwipeActionsConfig) add(DismissDirection.EndToStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismiss(
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.pointerInteropFilter {
|
||||
if (it.action == MotionEvent.ACTION_DOWN) {
|
||||
showingTutorial = false
|
||||
}
|
||||
false
|
||||
},
|
||||
directions = dismissDirections,
|
||||
dismissThresholds = {
|
||||
if (it == DismissDirection.StartToEnd)
|
||||
FractionalThreshold(startActionsConfig.threshold)
|
||||
else FractionalThreshold(endActionsConfig.threshold)
|
||||
},
|
||||
background = {
|
||||
AnimatedContent(
|
||||
targetState = Pair(state.dismissDirection, willDismissDirection != null),
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
tween(0),
|
||||
initialAlpha = if (targetState.second) 1f else 0f,
|
||||
) togetherWith fadeOut(
|
||||
tween(0),
|
||||
targetAlpha = if (targetState.second) .7f else 0f,
|
||||
)
|
||||
}, label = "background"
|
||||
) { (direction, willDismiss) ->
|
||||
val revealSize = remember { Animatable(if (willDismiss) 0f else 0f) }
|
||||
val iconSize = remember { Animatable(if (willDismiss) .8f else 1f) }
|
||||
LaunchedEffect(key1 = Unit, block = {
|
||||
if (willDismiss) {
|
||||
revealSize.snapTo(0f)
|
||||
launch {
|
||||
revealSize.animateTo(1f, animationSpec = tween(500))
|
||||
}
|
||||
iconSize.snapTo(.8f)
|
||||
iconSize.animateTo(
|
||||
1.5f,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioHighBouncy,
|
||||
)
|
||||
)
|
||||
iconSize.animateTo(
|
||||
1f,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
color = when (direction) {
|
||||
DismissDirection.StartToEnd -> startActionsConfig.background
|
||||
DismissDirection.EndToStart -> endActionsConfig.background
|
||||
else -> Color.Transparent
|
||||
},
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(
|
||||
CirclePath(
|
||||
revealSize.value,
|
||||
direction == DismissDirection.StartToEnd
|
||||
)
|
||||
)
|
||||
.background(
|
||||
color = when (direction) {
|
||||
DismissDirection.StartToEnd -> startActionsConfig.backgroundActive
|
||||
DismissDirection.EndToStart -> endActionsConfig.backgroundActive
|
||||
else -> Color.Transparent
|
||||
},
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(
|
||||
when (direction) {
|
||||
DismissDirection.StartToEnd -> Alignment.CenterStart
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.scale(iconSize.value),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (direction) {
|
||||
DismissDirection.StartToEnd -> {
|
||||
if (startActionsConfig.icon !== null) {
|
||||
Image(
|
||||
imageVector = startActionsConfig.icon,
|
||||
colorFilter = ColorFilter.tint(if (willDismiss) startActionsConfig.iconTint else startActionsConfig.backgroundActive),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
DismissDirection.EndToStart -> {
|
||||
if (endActionsConfig.icon !== null) {
|
||||
Image(
|
||||
imageVector = endActionsConfig.icon,
|
||||
colorFilter = ColorFilter.tint(if (willDismiss) endActionsConfig.iconTint else endActionsConfig.backgroundActive),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
content(state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CirclePath(private val progress: Float, private val start: Boolean) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
|
||||
val origin = Offset(
|
||||
x = if (start) size.height / 2 else size.width - size.height / 2,
|
||||
y = size.center.y,
|
||||
)
|
||||
|
||||
val radius = (sqrt(
|
||||
size.height * size.height + size.width * size.width
|
||||
) * 1f) * progress
|
||||
|
||||
return Outline.Generic(
|
||||
Path().apply {
|
||||
addOval(
|
||||
Rect(
|
||||
center = origin,
|
||||
radius = radius,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Preview(widthDp = 300)
|
||||
@Composable
|
||||
private fun PreviewDefault() {
|
||||
TokushoTheme {
|
||||
SwipeActions(
|
||||
startActionsConfig = SwipeActionsConfig(
|
||||
threshold = 0.4f,
|
||||
background = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
backgroundActive = MaterialTheme.colorScheme.tertiary,
|
||||
iconTint = MaterialTheme.colorScheme.onTertiary,
|
||||
icon = Icons.Outlined.Edit,
|
||||
stayDismissed = false,
|
||||
onDismiss = {
|
||||
|
||||
}
|
||||
),
|
||||
endActionsConfig = SwipeActionsConfig(
|
||||
threshold = 0.4f,
|
||||
background = MaterialTheme.colorScheme.errorContainer,
|
||||
backgroundActive = MaterialTheme.colorScheme.error,
|
||||
iconTint = MaterialTheme.colorScheme.onError,
|
||||
icon = Icons.Outlined.DeleteForever,
|
||||
stayDismissed = false,
|
||||
onDismiss = {
|
||||
|
||||
}
|
||||
),
|
||||
) { state ->
|
||||
val size = with(LocalDensity.current) {
|
||||
java.lang.Float.max(
|
||||
java.lang.Float.min(
|
||||
16.dp.toPx(),
|
||||
abs(state.offset.value)
|
||||
), 0f
|
||||
).toDp()
|
||||
}
|
||||
|
||||
val animateCorners by remember {
|
||||
derivedStateOf {
|
||||
state.offset.value.absoluteValue > 30
|
||||
}
|
||||
}
|
||||
val startCorners by animateDpAsState(
|
||||
targetValue = when {
|
||||
state.dismissDirection == DismissDirection.StartToEnd &&
|
||||
animateCorners -> 8.dp
|
||||
else -> 0.dp
|
||||
}, label = "startCorners"
|
||||
)
|
||||
val endCorners by animateDpAsState(
|
||||
targetValue = when {
|
||||
state.dismissDirection == DismissDirection.EndToStart &&
|
||||
animateCorners -> 8.dp
|
||||
else -> 0.dp
|
||||
}, label = "endCorners"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.height(IntrinsicSize.Min)
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
vertical = min(
|
||||
size / 4f,
|
||||
4.dp
|
||||
)
|
||||
)
|
||||
.clip(RoundedCornerShape(size)),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = startCorners,
|
||||
bottomStart = startCorners,
|
||||
topEnd = endCorners,
|
||||
bottomEnd = endCorners,
|
||||
),
|
||||
) {
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Swipe to dismiss",
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.xtimms.tokusho.sections.reader
|
||||
|
||||
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||
|
||||
data class ReaderContent(
|
||||
val pages: List<ReaderPage>,
|
||||
val state: ReaderState?
|
||||
)
|
||||
@ -0,0 +1,28 @@
|
||||
package org.xtimms.tokusho.sections.reader
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.model.MangaHistory
|
||||
|
||||
@Parcelize
|
||||
data class ReaderState(
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(history: MangaHistory) : this(
|
||||
chapterId = history.chapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
)
|
||||
|
||||
constructor(manga: Manga, branch: String?) : this(
|
||||
chapterId = manga.chapters?.firstOrNull {
|
||||
it.branch == branch
|
||||
}?.id ?: error("Cannot find first chapter"),
|
||||
page = 0,
|
||||
scroll = 0,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package org.xtimms.tokusho.sections.reader
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.material.slider.Slider
|
||||
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.xtimms.tokusho.core.components.AppBarTitle
|
||||
import org.xtimms.tokusho.core.components.BackIconButton
|
||||
|
||||
const val READER_DESTINATION = "reader"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderView(
|
||||
readerViewModel: ReaderViewModel = hiltViewModel(),
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
|
||||
var sliderPosition by remember { mutableStateOf(0f) }
|
||||
val pagerState = rememberPagerState { sliderPosition.toInt() }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { AppBarTitle(title = "Test", subtitle = "Test") },
|
||||
colors = TopAppBarDefaults.topAppBarColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)),
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar {
|
||||
Slider(
|
||||
value = sliderPosition,
|
||||
valueRange = 0f..3f,
|
||||
steps = 3,
|
||||
onValueChange = { sliderPosition = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
HorizontalPager(
|
||||
modifier = Modifier.padding(padding),
|
||||
state = pagerState
|
||||
) {
|
||||
ZoomableAsyncImage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("https://images.unsplash.com/photo-1678465952838-c9d7f5daaa65")
|
||||
.crossfade(1_000)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Inside
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package org.xtimms.tokusho.sections.reader
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
|
||||
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
||||
import org.xtimms.tokusho.core.parser.MangaIntent
|
||||
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
|
||||
import org.xtimms.tokusho.sections.reader.domain.ChaptersLoader
|
||||
import org.xtimms.tokusho.sections.reader.domain.PageLoader
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ReaderViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val pageLoader: PageLoader,
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
) : KotatsuBaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
private var pageSaveJob: Job? = null
|
||||
private var bookmarkJob: Job? = null
|
||||
private var stateChangeJob: Job? = null
|
||||
|
||||
private val mangaData = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
||||
|
||||
val content = MutableStateFlow(ReaderContent(emptyList(), null))
|
||||
val manga: MangaDetails?
|
||||
get() = mangaData.value
|
||||
|
||||
init {
|
||||
loadImpl()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
loadingJob?.cancel()
|
||||
loadImpl()
|
||||
}
|
||||
|
||||
private fun loadImpl() {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package org.xtimms.tokusho.sections.reader.domain
|
||||
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.contains
|
||||
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||
|
||||
class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages {
|
||||
|
||||
// map chapterId to index in pages deque
|
||||
private val indices = LongSparseArray<IntRange>()
|
||||
|
||||
constructor() : this(ArrayDeque())
|
||||
|
||||
val chaptersSize: Int
|
||||
get() = indices.size()
|
||||
|
||||
@Synchronized
|
||||
fun removeFirst() {
|
||||
val chapterId = pages.first().chapterId
|
||||
indices.remove(chapterId)
|
||||
var delta = 0
|
||||
while (pages.first().chapterId == chapterId) {
|
||||
pages.removeFirst()
|
||||
delta--
|
||||
}
|
||||
shiftIndices(delta)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeLast() {
|
||||
val chapterId = pages.last().chapterId
|
||||
indices.remove(chapterId)
|
||||
while (pages.last().chapterId == chapterId) {
|
||||
pages.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addLast(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||
if (id in indices) {
|
||||
return false
|
||||
}
|
||||
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||
pages.addAll(newPages)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addFirst(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||
if (id in indices) {
|
||||
return false
|
||||
}
|
||||
shiftIndices(newPages.size)
|
||||
indices.put(id, newPages.indices)
|
||||
pages.addAll(0, newPages)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
indices.clear()
|
||||
pages.clear()
|
||||
}
|
||||
|
||||
fun size(id: Long) = indices[id]?.run {
|
||||
endInclusive - start + 1
|
||||
} ?: 0
|
||||
|
||||
fun subList(id: Long): List<ReaderPage> {
|
||||
val range = indices[id] ?: return emptyList()
|
||||
return pages.subList(range.first, range.last + 1)
|
||||
}
|
||||
|
||||
operator fun contains(chapterId: Long) = chapterId in indices
|
||||
|
||||
private fun shiftIndices(delta: Int) {
|
||||
for (i in 0 until indices.size()) {
|
||||
val range = indices.valueAt(i)
|
||||
indices.setValueAt(i, range + delta)
|
||||
}
|
||||
}
|
||||
|
||||
private operator fun IntRange.plus(delta: Int): IntRange {
|
||||
return IntRange(start + delta, endInclusive + delta)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package org.xtimms.tokusho.sections.reader.domain
|
||||
|
||||
import androidx.collection.LongSparseArray
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGES_TRIM_THRESHOLD = 120
|
||||
|
||||
@ViewModelScoped
|
||||
class ChaptersLoader @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
private val chapters = LongSparseArray<MangaChapter>()
|
||||
private val chapterPages = ChapterPages()
|
||||
private val mutex = Mutex()
|
||||
|
||||
val size: Int
|
||||
get() = chapters.size()
|
||||
|
||||
suspend fun init(manga: MangaDetails) = mutex.withLock {
|
||||
chapters.clear()
|
||||
manga.allChapters.forEach {
|
||||
chapters.put(it.id, it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
|
||||
val chapters = manga.allChapters
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||
if (index == -1) return
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||
val newPages = loadChapter(newChapter.id)
|
||||
mutex.withLock {
|
||||
if (chapterPages.chaptersSize > 1) {
|
||||
// trim pages
|
||||
if (chapterPages.size > PAGES_TRIM_THRESHOLD) {
|
||||
if (isNext) {
|
||||
chapterPages.removeFirst()
|
||||
} else {
|
||||
chapterPages.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNext) {
|
||||
chapterPages.addLast(newChapter.id, newPages)
|
||||
} else {
|
||||
chapterPages.addFirst(newChapter.id, newPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSingleChapter(chapterId: Long) {
|
||||
val pages = loadChapter(chapterId)
|
||||
mutex.withLock {
|
||||
chapterPages.clear()
|
||||
chapterPages.addLast(chapterId, pages)
|
||||
}
|
||||
}
|
||||
|
||||
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
|
||||
|
||||
fun hasPages(chapterId: Long): Boolean {
|
||||
return chapterId in chapterPages
|
||||
}
|
||||
|
||||
fun getPages(chapterId: Long): List<ReaderPage> {
|
||||
return chapterPages.subList(chapterId)
|
||||
}
|
||||
|
||||
fun getPagesCount(chapterId: Long): Int {
|
||||
return chapterPages.size(chapterId)
|
||||
}
|
||||
|
||||
fun last() = chapterPages.last()
|
||||
|
||||
fun first() = chapterPages.first()
|
||||
|
||||
fun snapshot() = chapterPages.toList()
|
||||
|
||||
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
|
||||
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
|
||||
val repo = mangaRepositoryFactory.create(chapter.source)
|
||||
return repo.getPages(chapter).mapIndexed { index, page ->
|
||||
ReaderPage(page, index, chapterId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,255 @@
|
||||
package org.xtimms.tokusho.sections.reader.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.core.cache.PagesCache
|
||||
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||
import org.xtimms.tokusho.core.network.MangaHttpClient
|
||||
import org.xtimms.tokusho.core.network.interceptors.ImageProxyInterceptor
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
|
||||
import org.xtimms.tokusho.core.parser.local.isFileUri
|
||||
import org.xtimms.tokusho.core.parser.local.isZipUri
|
||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||
import org.xtimms.tokusho.utils.FileSize
|
||||
import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope
|
||||
import org.xtimms.tokusho.utils.lang.getCompletionResultOrNull
|
||||
import org.xtimms.tokusho.utils.lang.withProgress
|
||||
import org.xtimms.tokusho.utils.progress.ProgressDeferred
|
||||
import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP
|
||||
import org.xtimms.tokusho.utils.system.compressToPNG
|
||||
import org.xtimms.tokusho.utils.system.ensureRamAtLeast
|
||||
import org.xtimms.tokusho.utils.system.ensureSuccess
|
||||
import org.xtimms.tokusho.utils.system.exists
|
||||
import org.xtimms.tokusho.utils.system.isPowerSaveMode
|
||||
import org.xtimms.tokusho.utils.system.isTargetNotEmpty
|
||||
import org.xtimms.tokusho.utils.system.ramAvailable
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@ActivityRetainedScoped
|
||||
class PageLoader @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
lifecycle: ActivityRetainedLifecycle,
|
||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
) {
|
||||
|
||||
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
|
||||
|
||||
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
|
||||
private val semaphore = Semaphore(3)
|
||||
private val convertLock = Mutex()
|
||||
private val prefetchLock = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var repository: MangaRepository? = null
|
||||
private val prefetchQueue = LinkedList<MangaPage>()
|
||||
private val counter = AtomicInteger(0)
|
||||
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository
|
||||
// && settings.isPagesPreloadEnabled
|
||||
&& !context.isPowerSaveMode()
|
||||
&& !isLowRam()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun prefetch(pages: List<ReaderPage>) = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
for (page in pages.asReversed()) {
|
||||
if (tasks.containsKey(page.id)) {
|
||||
continue
|
||||
}
|
||||
prefetchQueue.offerFirst(page.toMangaPage())
|
||||
if (prefetchQueue.size > prefetchQueueLimit) {
|
||||
prefetchQueue.pollLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (counter.get() == 0) {
|
||||
onIdle()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
|
||||
var task = tasks[page.id]?.takeIf { it.isValid() }
|
||||
if (force) {
|
||||
task?.cancel()
|
||||
} else if (task?.isCancelled == false) {
|
||||
return task
|
||||
}
|
||||
task = loadPageAsyncImpl(page, force)
|
||||
synchronized(tasks) {
|
||||
tasks[page.id] = task
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
|
||||
return loadPageAsync(page, force).await()
|
||||
}
|
||||
|
||||
suspend fun convertBitmap(uri: Uri): Uri = convertLock.withLock {
|
||||
if (uri.isZipUri()) {
|
||||
val bitmap = runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(uri.schemeSpecificPart).use { zip ->
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
context.ensureRamAtLeast(entry.size * 2)
|
||||
zip.getInputStream(zip.getEntry(uri.fragment)).use {
|
||||
BitmapFactory.decodeStream(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.put(uri.toString(), bitmap).toUri()
|
||||
} else {
|
||||
val file = uri.toFile()
|
||||
context.ensureRamAtLeast(file.length() * 2)
|
||||
val image = runInterruptible(Dispatchers.IO) {
|
||||
BitmapFactory.decodeFile(file.absolutePath)
|
||||
}
|
||||
try {
|
||||
image.compressToPNG(file)
|
||||
} finally {
|
||||
image.recycle()
|
||||
}
|
||||
uri
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPageUrl(page: MangaPage): String {
|
||||
return getRepository(page.source).getPageUrl(page)
|
||||
}
|
||||
|
||||
private fun onIdle() = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
while (prefetchQueue.isNotEmpty()) {
|
||||
val page = prefetchQueue.pollFirst() ?: return@launch
|
||||
if (cache.get(page.url) == null) {
|
||||
synchronized(tasks) {
|
||||
tasks[page.id] = loadPageAsyncImpl(page, false)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
|
||||
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
|
||||
val deferred = loaderScope.async {
|
||||
if (!skipCache) {
|
||||
cache.get(page.url)?.let { return@async it.toUri() }
|
||||
}
|
||||
counter.incrementAndGet()
|
||||
try {
|
||||
loadPageImpl(page, progress)
|
||||
} finally {
|
||||
if (counter.decrementAndGet() == 0) {
|
||||
onIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ProgressDeferred(deferred, progress)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getRepository(source: MangaSource): MangaRepository {
|
||||
val result = repository
|
||||
return if (result != null && result.source == source) {
|
||||
result
|
||||
} else {
|
||||
mangaRepositoryFactory.create(source).also { repository = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
|
||||
val pageUrl = getPageUrl(page)
|
||||
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
|
||||
val uri = Uri.parse(pageUrl)
|
||||
return when {
|
||||
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
|
||||
uri
|
||||
} else { // legacy uri
|
||||
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
|
||||
}
|
||||
|
||||
uri.isFileUri() -> uri
|
||||
else -> {
|
||||
val request = createPageRequest(page, pageUrl)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||
val body = checkNotNull(response.body) { "Null response body" }
|
||||
body.withProgress(progress).use {
|
||||
cache.put(pageUrl, it.source())
|
||||
}
|
||||
}.toUri()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLowRam(): Boolean {
|
||||
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
|
||||
}
|
||||
|
||||
private fun Deferred<Uri>.isValid(): Boolean {
|
||||
return getCompletionResultOrNull()?.map { uri ->
|
||||
uri.exists() && uri.isTargetNotEmpty()
|
||||
}?.getOrDefault(false) ?: true
|
||||
}
|
||||
|
||||
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||
CoroutineExceptionHandler {
|
||||
|
||||
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||
exception.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PROGRESS_UNDEFINED = -1f
|
||||
private const val PREFETCH_LIMIT_DEFAULT = 6
|
||||
private const val PREFETCH_MIN_RAM_MB = 80L
|
||||
|
||||
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
|
||||
.url(pageUrl)
|
||||
.get()
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||
.tag(MangaSource::class.java, page.source)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package org.xtimms.tokusho.sections.reader.pager
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@Parcelize
|
||||
data class ReaderPage(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val preview: String?,
|
||||
val chapterId: Long,
|
||||
val index: Int,
|
||||
val source: MangaSource,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(page: MangaPage, index: Int, chapterId: Long) : this(
|
||||
id = page.id,
|
||||
url = page.url,
|
||||
preview = page.preview,
|
||||
chapterId = chapterId,
|
||||
index = index,
|
||||
source = page.source,
|
||||
)
|
||||
|
||||
fun toMangaPage() = MangaPage(
|
||||
id = id,
|
||||
url = url,
|
||||
preview = preview,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
package org.xtimms.tokusho.sections.reader.thumbnails
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import coil.ImageLoader
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import org.xtimms.tokusho.core.cache.PagesCache
|
||||
import org.xtimms.tokusho.core.network.MangaHttpClient
|
||||
import org.xtimms.tokusho.core.network.interceptors.ImageProxyInterceptor
|
||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||
import org.xtimms.tokusho.core.parser.local.isFileUri
|
||||
import org.xtimms.tokusho.core.parser.local.isZipUri
|
||||
import org.xtimms.tokusho.sections.reader.domain.PageLoader
|
||||
import org.xtimms.tokusho.utils.withExtraCloseable
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
|
||||
class MangaPageFetcher(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
private val options: Options,
|
||||
private val page: MangaPage,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
) : Fetcher {
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val repo = mangaRepositoryFactory.create(page.source)
|
||||
val pageUrl = repo.getPageUrl(page)
|
||||
pagesCache.get(pageUrl)?.let { file ->
|
||||
return SourceResult(
|
||||
source = ImageSource(
|
||||
file = file.toOkioPath(),
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
return loadPage(pageUrl)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun loadPage(pageUrl: String): SourceResult {
|
||||
val uri = pageUrl.toUri()
|
||||
return when {
|
||||
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
|
||||
val file = uri.toFile()
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
source = file.source().buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||
check(response.isSuccessful) {
|
||||
"Invalid response: ${response.code} ${response.message} at $pageUrl"
|
||||
}
|
||||
val body = checkNotNull(response.body) {
|
||||
"Null response"
|
||||
}
|
||||
val mimeType = response.mimeType
|
||||
val file = body.use {
|
||||
pagesCache.put(pageUrl, it.source())
|
||||
}
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
file = file.toOkioPath(),
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = mimeType,
|
||||
dataSource = DataSource.NETWORK,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
) : Fetcher.Factory<MangaPage> {
|
||||
|
||||
override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return MangaPageFetcher(
|
||||
okHttpClient = okHttpClient,
|
||||
pagesCache = pagesCache,
|
||||
options = options,
|
||||
page = data,
|
||||
context = context,
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
imageProxyInterceptor = imageProxyInterceptor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata()
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package org.xtimms.tokusho.sections.settings.sources.catalog
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import coil.ImageLoader
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.components.MangaGridItem
|
||||
import org.xtimms.tokusho.core.screens.EmptyScreen
|
||||
import org.xtimms.tokusho.sections.shelf.LazyShelfGrid
|
||||
import org.xtimms.tokusho.sections.shelf.ShelfGrid
|
||||
import org.xtimms.tokusho.sections.shelf.ShelfManga
|
||||
import org.xtimms.tokusho.utils.system.plus
|
||||
|
||||
@Composable
|
||||
fun SourcesCatalogPager(
|
||||
coil: ImageLoader,
|
||||
state: PagerState,
|
||||
contentPadding: PaddingValues,
|
||||
searchQuery: String?,
|
||||
getSourcesForPage: (Int) -> List<SourceCatalogPage>,
|
||||
) {
|
||||
HorizontalPager(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = state,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) { page ->
|
||||
|
||||
if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) {
|
||||
// To make sure only one offscreen page is being composed
|
||||
return@HorizontalPager
|
||||
}
|
||||
|
||||
val sources = getSourcesForPage(page)
|
||||
|
||||
if (sources.isEmpty()) {
|
||||
SourcesCatalogPagerEmptyScreen(
|
||||
searchQuery = searchQuery,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
return@HorizontalPager
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items(
|
||||
items = sources,
|
||||
) { item ->
|
||||
item.items.forEach { source ->
|
||||
SourceCatalogItem(
|
||||
coil = coil,
|
||||
source = source.source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourcesCatalogPagerEmptyScreen(
|
||||
searchQuery: String?,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
val msg = when {
|
||||
!searchQuery.isNullOrEmpty() -> R.string.no_results_found
|
||||
else -> R.string.information_no_manga_category
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding + PaddingValues(8.dp))
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
EmptyScreen(
|
||||
icon = Icons.Outlined.Close,
|
||||
title = R.string.empty_here,
|
||||
description = msg,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.xtimms.tokusho.utils.progress
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ProgressDeferred<T, P>(
|
||||
private val deferred: Deferred<T>,
|
||||
private val progress: StateFlow<P>,
|
||||
) : Deferred<T> by deferred {
|
||||
|
||||
val progressValue: P
|
||||
get() = progress.value
|
||||
|
||||
fun progressAsFlow(): Flow<P> = progress
|
||||
}
|
||||
@ -1,4 +1,33 @@
|
||||
package org.xtimms.tokusho.utils.system
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
const val URI_SCHEME_FILE = "file"
|
||||
const val URI_SCHEME_ZIP = "file+zip"
|
||||
|
||||
fun Uri.exists(): Boolean = when (scheme) {
|
||||
URI_SCHEME_FILE -> toFile().exists()
|
||||
URI_SCHEME_ZIP -> {
|
||||
val file = File(requireNotNull(schemeSpecificPart))
|
||||
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
|
||||
}
|
||||
|
||||
else -> unsupportedUri(this)
|
||||
}
|
||||
|
||||
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
||||
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
||||
URI_SCHEME_ZIP -> {
|
||||
val file = File(requireNotNull(schemeSpecificPart))
|
||||
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
|
||||
}
|
||||
|
||||
else -> unsupportedUri(this)
|
||||
}
|
||||
|
||||
private fun unsupportedUri(uri: Uri): Nothing {
|
||||
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
unqualifiedResLocale=en-US
|
||||
Loading…
Reference in New Issue