mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 15:49:30 +00:00
feat: gif support for collections
This commit is contained in:
parent
65fcc44239
commit
29e3a89eda
14 changed files with 408 additions and 35 deletions
|
|
@ -206,6 +206,7 @@ kotlin {
|
|||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.coil.gif)
|
||||
implementation("androidx.recyclerview:recyclerview:1.4.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import android.os.Build
|
||||
import coil3.ImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
|
||||
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder =
|
||||
components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
|
||||
@Composable
|
||||
internal actual fun CollectionCardRemoteImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String,
|
||||
modifier: Modifier,
|
||||
contentScale: ContentScale,
|
||||
animateIfPossible: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val request: ImageRequest = remember(context, imageUrl) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.memoryCacheKey("home-collection:$imageUrl")
|
||||
.diskCacheKey(imageUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ import com.nuvio.app.core.sync.SyncManager
|
|||
import com.nuvio.app.core.ui.NuvioNavigationBar
|
||||
import com.nuvio.app.core.ui.NuvioPosterActionSheet
|
||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||
import com.nuvio.app.core.ui.configurePlatformImageLoader
|
||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.features.auth.AuthScreen
|
||||
|
|
@ -248,6 +249,7 @@ fun App() {
|
|||
.crossfade(true)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.configurePlatformImageLoader()
|
||||
.build()
|
||||
}
|
||||
val selectedTheme by remember {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import coil3.ImageLoader
|
||||
|
||||
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder
|
||||
|
|
@ -14,6 +14,7 @@ data class CollectionEditorUiState(
|
|||
val title: String = "",
|
||||
val backdropImageUrl: String = "",
|
||||
val pinToTop: Boolean = false,
|
||||
val focusGlowEnabled: Boolean = true,
|
||||
val viewMode: FolderViewMode = FolderViewMode.TABBED_GRID,
|
||||
val showAllTab: Boolean = true,
|
||||
val folders: List<CollectionFolder> = emptyList(),
|
||||
|
|
@ -43,6 +44,7 @@ object CollectionEditorRepository {
|
|||
title = existing.title,
|
||||
backdropImageUrl = existing.backdropImageUrl.orEmpty(),
|
||||
pinToTop = existing.pinToTop,
|
||||
focusGlowEnabled = existing.focusGlowEnabled,
|
||||
viewMode = existing.folderViewMode,
|
||||
showAllTab = existing.showAllTab,
|
||||
folders = existing.folders,
|
||||
|
|
@ -59,6 +61,7 @@ object CollectionEditorRepository {
|
|||
title = "",
|
||||
backdropImageUrl = "",
|
||||
pinToTop = false,
|
||||
focusGlowEnabled = true,
|
||||
viewMode = FolderViewMode.TABBED_GRID,
|
||||
showAllTab = true,
|
||||
folders = emptyList(),
|
||||
|
|
@ -83,6 +86,10 @@ object CollectionEditorRepository {
|
|||
_uiState.value = _uiState.value.copy(pinToTop = pinToTop)
|
||||
}
|
||||
|
||||
fun setFocusGlowEnabled(enabled: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(focusGlowEnabled = enabled)
|
||||
}
|
||||
|
||||
fun setViewMode(viewMode: FolderViewMode) {
|
||||
_uiState.value = _uiState.value.copy(viewMode = viewMode)
|
||||
}
|
||||
|
|
@ -145,6 +152,20 @@ object CollectionEditorRepository {
|
|||
)
|
||||
}
|
||||
|
||||
fun updateFolderFocusGifUrl(url: String) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(focusGifUrl = url.ifBlank { null }),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFolderFocusGifEnabled(enabled: Boolean) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editingFolder = folder.copy(focusGifEnabled = enabled),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateFolderCoverEmoji(emoji: String) {
|
||||
val folder = _uiState.value.editingFolder ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
|
|
@ -256,6 +277,7 @@ object CollectionEditorRepository {
|
|||
title = state.title.trim(),
|
||||
backdropImageUrl = state.backdropImageUrl.ifBlank { null },
|
||||
pinToTop = state.pinToTop,
|
||||
focusGlowEnabled = state.focusGlowEnabled,
|
||||
viewMode = state.viewMode.name,
|
||||
showAllTab = state.showAllTab,
|
||||
folders = state.folders,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,43 @@ fun CollectionEditorScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Focus Glow
|
||||
item {
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { CollectionEditorRepository.setFocusGlowEnabled(!state.focusGlowEnabled) },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = "Always-On Card Glow",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Show glow for home folder cards all the time on touch devices.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.focusGlowEnabled,
|
||||
onCheckedChange = { CollectionEditorRepository.setFocusGlowEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// View Mode
|
||||
item {
|
||||
NuvioSurfaceCard {
|
||||
|
|
@ -479,6 +516,38 @@ private fun FolderEditorSheet(
|
|||
placeholder = "Image URL",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioInputField(
|
||||
value = folder.focusGifUrl.orEmpty(),
|
||||
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) },
|
||||
placeholder = "Always-play GIF URL (optional)",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
CollectionEditorRepository.updateFolderFocusGifEnabled(!folder.focusGifEnabled)
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Show GIF When Configured",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Switch(
|
||||
checked = folder.focusGifEnabled,
|
||||
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ data class CollectionFolder(
|
|||
val id: String,
|
||||
val title: String,
|
||||
val coverImageUrl: String? = null,
|
||||
val focusGifUrl: String? = null,
|
||||
val focusGifEnabled: Boolean = true,
|
||||
val coverEmoji: String? = null,
|
||||
val tileShape: String = "Poster",
|
||||
val hideTitle: Boolean = false,
|
||||
|
|
@ -56,6 +58,7 @@ data class Collection(
|
|||
val title: String,
|
||||
val backdropImageUrl: String? = null,
|
||||
val pinToTop: Boolean = false,
|
||||
val focusGlowEnabled: Boolean = true,
|
||||
val viewMode: String = "TABBED_GRID",
|
||||
val showAllTab: Boolean = true,
|
||||
val folders: List<CollectionFolder> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
@Composable
|
||||
internal expect fun CollectionCardRemoteImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
animateIfPossible: Boolean = false,
|
||||
)
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -8,23 +7,25 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.features.collection.Collection
|
||||
import com.nuvio.app.features.collection.CollectionFolder
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
|
@ -84,40 +85,55 @@ private fun CollectionFolderCard(
|
|||
modifier = modifier.width(cardWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
val shapeCorner = RoundedCornerShape(16.dp)
|
||||
val imageUrl = collectionFolderCardImageUrl(folder)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.then(
|
||||
if (onClick != null) Modifier.clickable(onClick = onClick)
|
||||
else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
.aspectRatio(aspectRatio),
|
||||
shape = shapeCorner,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 2.dp,
|
||||
),
|
||||
) {
|
||||
when {
|
||||
!folder.coverImageUrl.isNullOrBlank() -> {
|
||||
AsyncImage(
|
||||
model = folder.coverImageUrl,
|
||||
contentDescription = folder.title,
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
!imageUrl.isNullOrBlank() -> {
|
||||
CollectionCardRemoteImage(
|
||||
imageUrl = imageUrl,
|
||||
contentDescription = folder.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
|
||||
)
|
||||
}
|
||||
!folder.coverEmoji.isNullOrBlank() -> {
|
||||
Text(
|
||||
text = folder.coverEmoji,
|
||||
fontSize = 36.sp,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = folder.title.take(2).uppercase(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
!folder.coverEmoji.isNullOrBlank() -> {
|
||||
Text(
|
||||
text = folder.coverEmoji,
|
||||
fontSize = 36.sp,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = folder.title.take(2).uppercase(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
.fillMaxSize()
|
||||
.posterCardClickable(onClick = onClick, onLongClick = null),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -134,3 +150,23 @@ private fun CollectionFolderCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
|
||||
return if (folder.focusGifEnabled) {
|
||||
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
|
||||
} else {
|
||||
firstNonBlank(folder.coverImageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstNonBlank(vararg candidates: String?): String? {
|
||||
return candidates.firstOrNull { !it.isNullOrBlank() }?.trim()
|
||||
}
|
||||
|
||||
private fun isAnimatedCollectionFolderImage(
|
||||
folder: CollectionFolder,
|
||||
imageUrl: String,
|
||||
): Boolean {
|
||||
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
|
||||
return folder.focusGifEnabled && imageUrl == gifUrl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import coil3.ImageLoader
|
||||
|
||||
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.interop.UIKitView
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
import io.ktor.client.request.get
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.reinterpret
|
||||
import platform.CoreGraphics.CGImageRef
|
||||
import platform.CoreFoundation.CFDataCreate
|
||||
import platform.ImageIO.CGImageSourceCreateImageAtIndex
|
||||
import platform.ImageIO.CGImageSourceCreateWithData
|
||||
import platform.ImageIO.CGImageSourceGetCount
|
||||
import platform.UIKit.UIImage
|
||||
import platform.UIKit.UIImageView
|
||||
import platform.UIKit.UIViewContentMode
|
||||
import platform.CoreGraphics.CGImageRelease
|
||||
import kotlinx.cinterop.usePinned
|
||||
|
||||
private val gifHttpClient = HttpClient(Darwin)
|
||||
private val gifDecodeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private const val MaxCachedGifImages = 12
|
||||
private val gifImageCache = mutableMapOf<String, UIImage>()
|
||||
private val gifImageCacheOrder = mutableListOf<String>()
|
||||
private val gifImageInFlight = mutableMapOf<String, Deferred<UIImage?>>()
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
@Composable
|
||||
internal actual fun CollectionCardRemoteImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String,
|
||||
modifier: Modifier,
|
||||
contentScale: ContentScale,
|
||||
animateIfPossible: Boolean,
|
||||
) {
|
||||
if (!animateIfPossible) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var gifImage by remember(imageUrl) { mutableStateOf(cachedGifImage(imageUrl)) }
|
||||
|
||||
LaunchedEffect(imageUrl) {
|
||||
gifImage = loadGifImage(imageUrl)
|
||||
}
|
||||
|
||||
UIKitView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
UIImageView().apply {
|
||||
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
|
||||
clipsToBounds = true
|
||||
userInteractionEnabled = false
|
||||
image = gifImage
|
||||
tag = imageUrl.hashCode().toLong()
|
||||
}
|
||||
},
|
||||
update = { imageView ->
|
||||
if (imageView.tag != imageUrl.hashCode().toLong()) {
|
||||
imageView.tag = imageUrl.hashCode().toLong()
|
||||
}
|
||||
imageView.image = gifImage
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun cachedGifImage(imageUrl: String): UIImage? {
|
||||
val image = gifImageCache[imageUrl] ?: return null
|
||||
gifImageCacheOrder.remove(imageUrl)
|
||||
gifImageCacheOrder.add(imageUrl)
|
||||
return image
|
||||
}
|
||||
|
||||
private fun storeGifImage(imageUrl: String, image: UIImage) {
|
||||
gifImageCache[imageUrl] = image
|
||||
gifImageCacheOrder.remove(imageUrl)
|
||||
gifImageCacheOrder.add(imageUrl)
|
||||
|
||||
while (gifImageCacheOrder.size > MaxCachedGifImages) {
|
||||
val eldestKey = gifImageCacheOrder.removeFirstOrNull() ?: break
|
||||
gifImageCache.remove(eldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private suspend fun loadGifImage(imageUrl: String): UIImage? {
|
||||
cachedGifImage(imageUrl)?.let { return it }
|
||||
|
||||
val request = gifImageInFlight[imageUrl] ?: gifDecodeScope.async {
|
||||
runCatching {
|
||||
val bytes = gifHttpClient.get(imageUrl).body<ByteArray>()
|
||||
bytes
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.toCFData()
|
||||
?.let { UIImage.gifImageWithData(it) }
|
||||
}.getOrNull()
|
||||
}.also { gifImageInFlight[imageUrl] = it }
|
||||
|
||||
val image = try {
|
||||
request.await()
|
||||
} finally {
|
||||
if (gifImageInFlight[imageUrl] === request) {
|
||||
gifImageInFlight.remove(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
storeGifImage(imageUrl, image)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun ByteArray.toCFData() =
|
||||
usePinned { pinned ->
|
||||
CFDataCreate(
|
||||
allocator = null,
|
||||
bytes = pinned.addressOf(0).reinterpret(),
|
||||
length = size.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun UIImage.Companion.gifImageWithData(data: kotlinx.cinterop.CPointer<cnames.structs.__CFData>?): UIImage? {
|
||||
return runCatching {
|
||||
val source = data?.let { CGImageSourceCreateWithData(it, null) } ?: return null
|
||||
val count = CGImageSourceGetCount(source).toInt()
|
||||
val frames = mutableListOf<UIImage>()
|
||||
|
||||
for (index in 0 until count) {
|
||||
val imageRef: CGImageRef = CGImageSourceCreateImageAtIndex(source, index.toULong(), null) ?: continue
|
||||
try {
|
||||
frames.add(UIImage.imageWithCGImage(imageRef))
|
||||
} finally {
|
||||
CGImageRelease(imageRef)
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.isEmpty()) return null
|
||||
|
||||
val durationSeconds = (count * 0.1).coerceAtLeast(0.1)
|
||||
UIImage.animatedImageWithImages(frames, durationSeconds)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul
|
|||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
CURRENT_PROJECT_VERSION=10
|
||||
MARKETING_VERSION=0.1.0-alpha10
|
||||
CURRENT_PROJECT_VERSION=11
|
||||
MARKETING_VERSION=0.1.0-alpha11
|
||||
Loading…
Reference in a new issue