feat: gif support for collections

This commit is contained in:
tapframe 2026-04-07 11:01:54 +05:30
parent 65fcc44239
commit 29e3a89eda
14 changed files with 408 additions and 35 deletions

View file

@ -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")

View file

@ -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())
}
}

View file

@ -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,
)
}

View file

@ -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 {

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder

View file

@ -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,

View file

@ -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,
),
)
}
}
}

View file

@ -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(),

View file

@ -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,
)

View file

@ -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
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this

View file

@ -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()
}

View file

@ -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" }

View file

@ -1,2 +1,2 @@
CURRENT_PROJECT_VERSION=10
MARKETING_VERSION=0.1.0-alpha10
CURRENT_PROJECT_VERSION=11
MARKETING_VERSION=0.1.0-alpha11