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:
@@ -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
|
||||
// ---
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+56
-1
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+32
-9
@@ -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
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user