Skip to content

Commit

Permalink
✨ Completed the lazy thumbnail loading and added caching functionality
Browse files Browse the repository at this point in the history
This update finalizes the thumbnail rendering feature in the SongFinder app.
Primarily,
we added the ability to lazily load thumbnail images depending on the PV availability.
If a thumbnail fails to load,
the app now automatically tries the next available PV
until a successful thumbnail load occurs
or all options are exhausted.
In the latter case, a failure icon is displayed.
Further,
a caching mechanism has been introduced to speed up thumbnail retrieval.
This cache is evicted every time the user move on to the next song.
  • Loading branch information
CXwudi committed Dec 7, 2023
1 parent 3d63c91 commit a679aab
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ResultCellController(
// First, write the chosen song to csv
csvLineWriter.writeSongId(chosenSong.id.toULong())
progressStateModel.increment()
findThumbnailService.evictCache()

// Then, read the next song title from the input file. If no more song, then we are done
val nextSongTitle = inputFileLineReader.readNext()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,83 @@
package mikufan.cx.songfinder.backend.service

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import mikufan.cx.inlinelogging.KInlineLogging
import mikufan.cx.songfinder.backend.component.thumbnailfinder.ThumbnailException
import mikufan.cx.songfinder.backend.component.thumbnailfinder.ThumbnailFinder
import mikufan.cx.songfinder.backend.model.PVInfo
import mikufan.cx.songfinder.backend.model.ThumbnailInfo
import org.springframework.cache.CacheManager
import org.springframework.cache.get
import org.springframework.stereotype.Service

@Service
class FindThumbnailService(
thumbnailFinders: List<ThumbnailFinder>,
private val cacheManager: CacheManager,
) {

private val finderMap = thumbnailFinders.associateBy { it.matchedPvService }

suspend fun tryGetThumbnail(pv: PVInfo): Result<ThumbnailInfo> {
companion object {
const val CACHE_NAME = "thumbnail"
}

// @Cacheable("thumbnail", key = "#pv.id + #pv.pvService")
suspend fun tryGetThumbnail(pv: PVInfo): Result<ThumbnailInfo> = withContext(ThumbnailFinder.defaultDispatcher) {
val cachedThumbnailInfoResult = cacheManager[CACHE_NAME]?.get<Result<ThumbnailInfo>>(pv.id + pv.pvService)
if (cachedThumbnailInfoResult != null) {
log.debug { "Found cached thumbnail info $cachedThumbnailInfoResult for PV $pv" }
cachedThumbnailInfoResult
} else {
doGetAndSaveCacheConditionally(pv)
}
}

private suspend fun doGetAndSaveCacheConditionally(pv: PVInfo): Result<ThumbnailInfo> {
val finder = finderMap[pv.pvService]
return if (finder == null) {
Result.failure(IllegalArgumentException("No thumbnail finder for pv service ${pv.pvService}"))
val r = Result.failure<ThumbnailInfo>(IllegalArgumentException("No thumbnail finder for pv service ${pv.pvService}"))
cachePut(pv, r)
r
} else {
try {
Result.success(finder.findThumbnail(pv))
log.info { "First time searching thumbnail for PV $pv, try finding" }
val thumbnailInfo = finder.findThumbnail(pv)
log.info { "Successfully found thumbnail info $thumbnailInfo for PV $pv" }
val r = Result.success(thumbnailInfo)
cachePut(pv, r)
r
} catch (e: ThumbnailException) {
log.warn { "Failed to find thumbnail info for PV $pv, exception: ${e.message}" }
val r = Result.failure<ThumbnailInfo>(e)
cachePut(pv, r)
r
} catch (e: CancellationException) {
log.info {
"Cancellation happens upon finding thumbnail info for PV $pv, " +
"likely this happens when we are scrolling too fast. avoiding caching and returning. $e"
}
Result.failure(e)
} catch (e: Exception) {
log.warn(e) {
"Failed to find thumbnail info for PV $pv due to unexpected exception, " +
"avoiding caching and returning"
}
Result.failure(e)
}
}
}

private fun cachePut(pv: PVInfo, thumbnailInfoResult: Result<ThumbnailInfo>) {
cacheManager[CACHE_NAME]?.put(pv.id + pv.pvService, thumbnailInfoResult)
}

// @CacheEvict("thumbnail", allEntries = true)
fun evictCache() {
TODO("return back here once spring cache is added")
log.info { "Evicting all thumbnail cache" }
cacheManager[CACHE_NAME]?.clear()
}
}
}

private val log = KInlineLogging.logger()
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import mikufan.cx.songfinder.backend.db.entity.PvType
import mikufan.cx.songfinder.backend.db.entity.SongType
import mikufan.cx.songfinder.backend.model.PVInfo
import mikufan.cx.songfinder.backend.model.SongSearchResult
import mikufan.cx.songfinder.backend.model.ThumbnailInfo
import mikufan.cx.songfinder.backend.statemodel.SearchRegexOption
import mikufan.cx.songfinder.backend.statemodel.SearchStatus
import mikufan.cx.songfinder.getSpringBean
Expand Down Expand Up @@ -95,7 +96,7 @@ fun PreviewMainScreen() {
)
)
) {
RealResultGridCell(it, ResultCellCallbacks({}, { "" }))
RealResultGridCell(it, ResultCellCallbacks({}, { Result.success(ThumbnailInfo("url", {})) }))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package mikufan.cx.songfinder.ui.component.main

import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.basicMarquee
Expand All @@ -9,13 +11,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -31,13 +28,17 @@ import compose.icons.simpleicons.Bilibili
import compose.icons.simpleicons.Niconico
import compose.icons.simpleicons.Soundcloud
import compose.icons.simpleicons.Youtube
import io.kamel.image.KamelImage
import io.kamel.image.asyncPainterResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mikufan.cx.songfinder.backend.component.thumbnailfinder.ThumbnailFinder
import mikufan.cx.songfinder.backend.controller.mainpage.ResultCellController
import mikufan.cx.songfinder.backend.db.entity.PvService
import mikufan.cx.songfinder.backend.db.entity.SongType
import mikufan.cx.songfinder.backend.model.PVInfo
import mikufan.cx.songfinder.backend.model.SongSearchResult
import mikufan.cx.songfinder.backend.model.ThumbnailInfo
import mikufan.cx.songfinder.getSpringBean
import mikufan.cx.songfinder.ui.common.TooltipAreaWithCard
import mikufan.cx.songfinder.ui.theme.spacing
Expand All @@ -59,7 +60,7 @@ fun LazyGridItemScope.ResultGridCell(
) {
val callbacks = ResultCellCallbacks(
onCardClicked = { scopeFromIrremovableParent.launch { controller.handleRecord(it) } },
provideThumbnailUrl = { TODO() }
provideThumbnailInfo = controller::tryGetThumbnail
)
RealResultGridCell(result, callbacks)
}
Expand Down Expand Up @@ -92,33 +93,103 @@ fun LazyGridItemScope.RealResultGridCell(
onCardClicked = { callbacks.onCardClicked(result) },
modifier.animateItemPlacement()
) {
LazyThumbnailImage(filteredPvs)
LazyThumbnailImage(filteredPvs, provideThumbnailInfoCallback = callbacks.provideThumbnailInfo)
MusicInfo(result, filteredPvs)
}
}

/**
* Composable function to display a lazy loading thumbnail image for a song search result.
* Lazily fetch and display thumbnail, using the first ever successful thumbnail URL from the
* given list of PVs.
*
* @param result The song search result
* @param pvs The list of PV (Promotional Video) information
* @param pvs The list of PVInfo objects representing the thumbnail images to display.
* @param imageHolderModifier The modifier for styling the image holder.
* @param provideThumbnailInfoCallback The callback function for providing thumbnail information.
*/
@Composable
fun LazyThumbnailImage(
pvs: List<PVInfo>
pvs: List<PVInfo>,
imageHolderModifier: Modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(MaterialTheme.spacing.cornerShape)),
provideThumbnailInfoCallback: suspend (PVInfo) -> Result<ThumbnailInfo>
) {
//TODO: use the first ever available PV's thumbnail, if no PVs, use image not found.
// If exceptions (typically no available PVs), use image failed to load
// if no PVs, display a "no image" icon
if (pvs.isEmpty()) {
Image(
painter = painterResource("image/image-not-found-icon.svg"),
contentDescription = "Failed Thumbnail",
modifier = imageHolderModifier,
)
} else {
// else, starting from index 0
val urlHandler = LocalUriHandler.current
var currentPvInfoIndex by remember { mutableStateOf(0) }

// Loading process: starting from the first PV, if failed to load, try the next one. If all failed, use image not found
var loadStatus: ThumbnailInfoLoadStatus by remember { mutableStateOf(ThumbnailInfoLoadStatus.Loading) }
LaunchedEffect(currentPvInfoIndex) {
loadStatus = ThumbnailInfoLoadStatus.Loading
provideThumbnailInfoCallback(pvs[currentPvInfoIndex]).fold(
// if success, continue, else, try next
onSuccess = { loadStatus = ThumbnailInfoLoadStatus.Success(it) },
onFailure = {
if (currentPvInfoIndex < pvs.size - 1) {
currentPvInfoIndex++
} else {
loadStatus = ThumbnailInfoLoadStatus.Failure
}
}
)
}

Image(
painter = painterResource("image/image-not-found-icon.svg"),
contentDescription = "Thumbnail",
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(MaterialTheme.spacing.cornerShape))
)
Crossfade(loadStatus, animationSpec = tween()) {
when (loadStatus) {
is ThumbnailInfoLoadStatus.Loading -> Box(
modifier = imageHolderModifier,
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
// we reach here if all PVs failed to load the thumbnail
// render an "image failed" icon
is ThumbnailInfoLoadStatus.Failure -> Image(
painter = painterResource("image/image-load-failed.svg"),
contentDescription = "Failed Thumbnail",
modifier = imageHolderModifier,
)

is ThumbnailInfoLoadStatus.Success -> {
val thumbnailInfo = (loadStatus as ThumbnailInfoLoadStatus.Success).info
val resource = asyncPainterResource(thumbnailInfo.url) {
coroutineContext += ThumbnailFinder.ioDispatcher

requestBuilder {
thumbnailInfo.requestBuilder.invoke(this)
}
}

// using KamelImage, if the thumbnail URL works, it will be rendered
// else, move on the next PV
KamelImage(
resource = resource,
contentDescription = "Thumbnail",
modifier = imageHolderModifier
.clickable { urlHandler.openUri(thumbnailInfo.url) },
onLoading = { CircularProgressIndicator() },
onFailure = {
if (currentPvInfoIndex < pvs.size - 1) {
currentPvInfoIndex++
} else {
loadStatus = ThumbnailInfoLoadStatus.Failure
}
},
animationSpec = tween()
)
}
}
}

}
}

/**
Expand Down Expand Up @@ -291,12 +362,12 @@ private fun PvField(pvs: List<PVInfo>) {
*
* @property onCardClicked A callback function that is invoked when the result cell is clicked.
* It takes a [SongSearchResult] as a parameter and does not return any value.
* @property provideThumbnailUrl A callback function that asynchronously provides a thumbnail URL for the result cell.
* @property provideThumbnailInfo A callback function that asynchronously provides a thumbnail URL for the result cell.
* It takes a [SongSearchResult] as a parameter and returns a [String] representing the URL.
*/
data class ResultCellCallbacks(
val onCardClicked: (SongSearchResult) -> Unit,
val provideThumbnailUrl: suspend (SongSearchResult) -> String,
val provideThumbnailInfo: suspend (PVInfo) -> Result<ThumbnailInfo>,
)

private const val UnknownArtist = "Unknown Artist"
Expand Down Expand Up @@ -349,4 +420,13 @@ internal fun MusicCardTemplate(
content()
}
}
}

sealed interface ThumbnailInfoLoadStatus {
data object Loading : ThumbnailInfoLoadStatus
data class Success(
val info: ThumbnailInfo
) : ThumbnailInfoLoadStatus

data object Failure : ThumbnailInfoLoadStatus
}

0 comments on commit a679aab

Please sign in to comment.