feat(blossom): route image fetches through local Blossom cache

Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.

Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
This commit is contained in:
Claude
2026-05-07 12:10:06 +00:00
parent 4538812d26
commit 9c4e87b937
15 changed files with 599 additions and 22 deletions
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.resourceCacheInit
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
@@ -410,6 +411,10 @@ class AppModules(
}
}
val localBlossomCacheProbe by lazy {
LocalBlossomCacheProbe(roleBasedHttpClientBuilder)
}
val blossomResolver by lazy {
Log.d("AppModules", "BlossomServerResolver Init")
BlossomServerResolver(
@@ -426,6 +431,14 @@ class AppModules(
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
},
localCacheProbe = localBlossomCacheProbe,
)
}
@@ -575,6 +588,34 @@ class AppModules(
}
}
// Evict the BlossomServerResolver URL cache whenever the local-cache
// toggle flips or the probe transitions up/down so stale entries don't
// outlive the underlying decision.
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
if (state is AccountState.LoggedIn) {
state.account.settings.useLocalBlossomCache
.drop(1)
.collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
localBlossomCacheProbe.invalidate()
}
}
}
}
applicationIOScope.launch {
localBlossomCacheProbe.available.drop(1).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
}
}
// Warm the local-cache probe so the very first image load doesn't pay
// the loopback round-trip cost.
applicationIOScope.launch {
localBlossomCacheProbe.isAvailable()
}
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
// few hundred ms on a populated 4 GB cache — so leaving it for the first session's
@@ -95,6 +95,7 @@ private object PrefKeys {
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -346,6 +347,7 @@ object LocalPreferences {
)
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -513,6 +515,7 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - keys ready" }
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
@@ -620,6 +623,7 @@ object LocalPreferences {
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
@@ -149,6 +149,7 @@ class AccountSettings(
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -403,6 +404,13 @@ class AccountSettings(
}
}
fun changeUseLocalBlossomCache(enabled: Boolean) {
if (useLocalBlossomCache.value != enabled) {
useLocalBlossomCache.tryEmit(enabled)
saveAccountSettings()
}
}
// ---
// list names
// ---
@@ -41,6 +41,8 @@ class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
@@ -71,6 +73,10 @@ class BlossomServerResolver(
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
/**
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
* https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
*
* Issues a `HEAD /` request and caches the result with separate positive and
* negative TTLs so the loopback isn't probed on every image load.
*/
class LocalBlossomCacheProbe(
private val httpClientBuilder: IRoleBasedHttpClientBuilder,
) {
private val mutex = Mutex()
@Volatile
private var cachedAtMs: Long = 0L
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available
suspend fun isAvailable(): Boolean {
val now = currentTimeMs()
val ttl = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
if (cachedAtMs != 0L && now - cachedAtMs < ttl) {
return _available.value
}
return mutex.withLock {
// Re-check inside the lock in case another caller just refreshed.
val now2 = currentTimeMs()
val ttl2 = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
if (cachedAtMs != 0L && now2 - cachedAtMs < ttl2) {
return@withLock _available.value
}
val newResult = probe()
_available.value = newResult
cachedAtMs = currentTimeMs()
newResult
}
}
/**
* Forces the next call to [isAvailable] to re-probe regardless of TTL.
*/
fun invalidate() {
cachedAtMs = 0L
}
private suspend fun probe(): Boolean =
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
private fun currentTimeMs(): Long = System.currentTimeMillis()
companion object {
const val LOCAL_CACHE_BASE: String = "http://127.0.0.1:24242"
private const val POSITIVE_TTL_MS = 60_000L
private const val NEGATIVE_TTL_MS = 10_000L
private const val PROBE_TIMEOUT_MS = 1_500L
}
}
@@ -22,20 +22,26 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -57,7 +63,7 @@ fun AllMediaServersScreen(
blossomServersViewModel.load()
}
MediaServersScaffold(blossomServersViewModel) {
MediaServersScaffold(blossomServersViewModel, accountViewModel) {
nav.popBack()
}
}
@@ -66,6 +72,7 @@ fun AllMediaServersScreen(
@Composable
fun MediaServersScaffold(
blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
) {
Scaffold(
@@ -105,7 +112,55 @@ fun MediaServersScaffold(
color = MaterialTheme.colorScheme.grayText,
)
LocalBlossomCacheToggle(accountViewModel)
HorizontalDivider()
AllMediaBody(blossomServersViewModel)
}
}
}
@Composable
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.useLocalBlossomCache
.collectAsStateWithLifecycle()
val probeAvailable by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
Column(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(id = R.string.use_local_blossom_cache),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringRes(id = R.string.use_local_blossom_cache_caption),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
Text(
text =
if (enabled && probeAvailable) {
stringRes(id = R.string.local_blossom_cache_detected)
} else if (enabled) {
stringRes(id = R.string.local_blossom_cache_not_detected)
} else {
""
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = enabled,
onCheckedChange = { accountViewModel.account.settings.changeUseLocalBlossomCache(it) },
)
}
}
}
@@ -75,6 +75,7 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@@ -89,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
@@ -566,6 +568,11 @@ private fun RenderImageOrVideo(
}
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val modifier =
if (ratio != null) {
@@ -576,7 +583,7 @@ private fun RenderImageOrVideo(
Box(modifier, contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = content.url,
videoUri = bridgedUrl,
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
@@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@@ -148,20 +150,26 @@ fun ZoomableContentView(
sourceBounds = coordinates.boundsInWindow()
}
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
when (content) {
is MediaUrlImage -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(content.url),
preloadUrls = listOf(bridgedUrl),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
if (content.isGif()) {
GifVideoView(
videoUri = content.url,
videoUri = bridgedUrl,
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
@@ -187,6 +195,10 @@ fun ZoomableContentView(
is MediaUrlVideo -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
@@ -200,7 +212,7 @@ fun ZoomableContentView(
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = content.url,
videoUri = bridgedUrl,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
@@ -465,17 +477,22 @@ fun UrlImageView(
}
val context = LocalContext.current
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModel =
if (fullResolution) {
remember(content.url, context) {
remember(bridgedUrl, context) {
ImageRequest
.Builder(context)
.data(content.url)
.data(bridgedUrl)
.size(Size.ORIGINAL)
.build()
}
} else {
content.url
bridgedUrl
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1183,9 +1200,15 @@ private suspend fun shareLocalVideoFile(
private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
val keys = mutableListOf(content.url)
val bridged = content.toCoilModel(true)
if (bridged != content.url) keys.add(bridged)
for (key in keys) {
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
}
return null
@@ -188,6 +188,28 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
/**
* `true` when both the per-account toggle is enabled AND the local
* Blossom cache HEAD probe currently sees `127.0.0.1:24242` as
* available. UI call sites use this to decide whether to convert plain
* http(s) URLs (with imeta sha256) into `blossom:` URIs so the request
* routes through the local cache.
*/
val useLocalBlossomBridge: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, probeUp -> toggle && probeUp }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
// Mock/test instances don't initialise Amethyst.instance.
MutableStateFlow(false)
}
val callManager =
CallManager(
signer = account.signer,
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
@@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -256,11 +258,16 @@ fun UrlImageView(
val isVideo = content is MediaUrlVideo
val artworkUri = (content as? MediaUrlVideo)?.artworkUri
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
// Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8
// playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending
// the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon.
// Skip the fetch in that case and render blurhash + play overlay directly.
val imageModelUrl = artworkUri ?: content.url
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModelUrl = artworkUri ?: bridgedUrl
val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
+5
View File
@@ -806,6 +806,11 @@
<string name="media_servers">Media Servers</string>
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
<string name="use_local_blossom_cache">Use local Blossom cache</string>
<string name="use_local_blossom_cache_caption">When a Blossom cache is running on this device (port 24242), route image and video downloads through it.</string>
<string name="local_blossom_cache_detected">Local cache detected on port 24242.</string>
<string name="local_blossom_cache_not_detected">Local cache not detected on port 24242.</string>
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>