feat: video thumbnails + full-screen zoomable viewer for Blossom files

Videos in the gallery now show a decoded first frame (Coil
VideoFrameDecoder) with a play badge instead of a generic glyph; the same
preview is reused in the detail header, with a type-glyph fallback for
blobs Coil can't decode (e.g. HLS playlists).

Tapping an image or video tile now opens a full-screen viewer: images are
zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and
videos play inline. Its top bar carries back, a new share action (system
share sheet for the blob URL), and a button that reveals the file's
storage matrix and sync/copy/open/share/report/delete actions in a bottom
drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to
that action sheet. The actions list is now a shared BlobActionsContent so
the sheet and the viewer drawer stay identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
This commit is contained in:
Claude
2026-07-24 21:26:35 +00:00
parent 3fb44dba60
commit ccd991c847
2 changed files with 334 additions and 123 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -32,11 +33,14 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -58,11 +62,13 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -79,15 +85,22 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
@@ -98,6 +111,8 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
fun BlossomBlobManagerScreen(
@@ -115,8 +130,8 @@ fun BlossomBlobManagerScreen(
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
// The tapped file, if any. We keep only the hash and re-resolve the row from the
// live list each recomposition so the open sheet stays in sync with mirror/delete
// updates (and closes itself when the last copy of the blob is deleted).
// live list each recomposition so the open viewer/sheet stays in sync with
// mirror/delete updates (and closes itself when the last copy of the blob is deleted).
var selectedHash by remember { mutableStateOf<HexKey?>(null) }
pendingPayment?.let { pending ->
@@ -131,14 +146,26 @@ fun BlossomBlobManagerScreen(
selectedHash?.let { hash ->
val selected = blobs.firstOrNull { it.hash == hash }
if (selected == null) {
selectedHash = null
} else {
BlobDetailSheet(
row = selected,
vm = vm,
onDismiss = { selectedHash = null },
)
when {
selected == null -> selectedHash = null
// Images and videos open in the full-screen zoomable viewer, which carries
// the actions in its own bottom drawer. Everything else (PDFs, arbitrary
// blobs) has nothing to zoom, so it goes straight to the actions sheet.
selected.url != null && selected.isViewable ->
BlossomBlobViewer(
row = selected,
vm = vm,
accountViewModel = accountViewModel,
onDismiss = { selectedHash = null },
)
else ->
BlobDetailSheet(
row = selected,
vm = vm,
onDismiss = { selectedHash = null },
)
}
}
@@ -213,6 +240,10 @@ fun BlossomBlobManagerScreen(
}
}
/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */
private val BlobRow.isViewable: Boolean
get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
Column(
@@ -262,9 +293,9 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) {
}
/**
* One gallery cell: a square thumbnail (image preview or a type glyph) with a small
* corner badge summarizing how many of the user's servers hold this blob. Tapping it
* opens [BlobDetailSheet] with the storage matrix and the sync/delete/report actions.
* One gallery cell: a square preview — the image itself, or a decoded first frame for a
* video (with a play badge) — plus a corner badge summarizing how many of the user's
* servers hold this blob. Tapping it opens the full-screen viewer / actions.
*/
@Composable
private fun GalleryTile(
@@ -279,23 +310,7 @@ private fun GalleryTile(
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable(onClick = onClick),
) {
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(34.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
BlobPreview(row = row, modifier = Modifier.fillMaxSize())
SyncBadge(
row = row,
@@ -304,6 +319,68 @@ private fun GalleryTile(
}
}
/**
* Renders a blob's visual preview inside [modifier]'s bounds: the image, or a video's
* first frame (decoded by Coil's VideoFrameDecoder) with a centered play glyph. Falls
* back to a type glyph while loading fails or for non-visual blobs (e.g. an HLS playlist
* Coil can't decode).
*/
@Composable
private fun BlobPreview(
row: BlobRow,
modifier: Modifier = Modifier,
glyphSize: Dp = 34.dp,
playIconSize: Dp = 40.dp,
) {
val isVideo = row.type?.startsWith("video/") == true
Box(modifier = modifier, contentAlignment = Alignment.Center) {
if (row.url != null && row.isViewable) {
SubcomposeAsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
if (isVideo) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
symbol = MaterialSymbols.PlayCircle,
contentDescription = null,
modifier = Modifier.size(playIconSize),
tint = Color.White,
)
}
}
}
is AsyncImagePainter.State.Error -> BlobGlyph(row, glyphSize)
else -> {}
}
}
} else {
BlobGlyph(row, glyphSize)
}
}
}
@Composable
private fun BlobGlyph(
row: BlobRow,
size: Dp,
) {
Icon(
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(size),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* Corner chip over a gallery tile: a green check when the blob is on every server, an
* amber cloud when some server is still missing it, plus a `present/total` count so the
@@ -342,7 +419,125 @@ private fun SyncBadge(
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
/**
* Full-screen viewer opened from a gallery tile: the image is zoomable/pannable and a
* video plays inline, matching the app's [com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog].
* The blob's storage matrix and its sync/copy/open/share/report/delete actions live in a
* bottom drawer reached from the top bar, so they don't cover the media until asked for.
*/
@Composable
private fun BlossomBlobViewer(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var drawerOpen by remember { mutableStateOf(false) }
val isVideo = row.type?.startsWith("video/") == true
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
val url = row.url
if (url != null && isVideo) {
val controllerVisible = remember { mutableStateOf(true) }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = url,
mimeType = row.type,
contentScale = ContentScale.Fit,
borderModifier = Modifier.fillMaxWidth(),
automaticallyStartPlayback = true,
controllerVisible = controllerVisible,
isFullscreen = true,
accountViewModel = accountViewModel,
)
}
} else if (url != null) {
val zoomState = rememberZoomState()
AsyncImage(
model = url,
contentDescription = row.hash,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().zoomable(zoomState),
)
}
// Top bar: back, share, and the drawer toggle.
Row(
modifier =
Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ViewerIconButton(MaterialSymbols.AutoMirrored.ArrowBack, stringRes(R.string.back), onDismiss)
Spacer(Modifier.weight(1f))
if (url != null) {
ViewerIconButton(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) {
shareUrl(context, url)
}
}
ViewerIconButton(MaterialSymbols.Info, stringRes(R.string.blossom_file_details)) {
drawerOpen = true
}
}
// Bottom drawer with the file's storage matrix and actions.
if (drawerOpen) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(onClick = { drawerOpen = false }),
)
Surface(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
) {
BlobActionsContent(
row = row,
vm = vm,
modifier =
Modifier
.fillMaxHeight(0.7f)
.navigationBarsPadding()
// Swallow taps so the scrim behind doesn't dismiss the drawer.
.clickable(enabled = false) {},
)
}
}
}
}
}
@Composable
private fun ViewerIconButton(
symbol: MaterialSymbol,
contentDescription: String,
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier.clip(CircleShape).background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(symbol = symbol, contentDescription = contentDescription, tint = Color.White)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlobDetailSheet(
row: BlobRow,
@@ -350,90 +545,118 @@ private fun BlobDetailSheet(
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
BlobActionsContent(row = row, vm = vm, modifier = Modifier.navigationBarsPadding())
}
}
/**
* The blob's detail + action list, shared by the [BlobDetailSheet] (non-visual blobs)
* and by [BlossomBlobViewer]'s bottom drawer: a preview + hash/size header, the "Stored
* on" per-server matrix, the sync (mirror-to-missing) button, and the
* copy/open/share/report/delete actions.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobActionsContent(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
modifier: Modifier = Modifier,
) {
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview + identity.
Row(verticalAlignment = Alignment.CenterVertically) {
DetailThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) {
Text(
text = row.hash.take(12) + "" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Column(
modifier =
modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview + identity.
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
BlobPreview(row = row, modifier = Modifier.fillMaxSize(), glyphSize = 28.dp, playIconSize = 28.dp)
}
// Where the file lives.
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) {
Text(
text = stringRes(R.string.blossom_stored_on),
style = MaterialTheme.typography.labelLarge,
text = row.hash.take(12) + "" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
row.servers.forEach { ServerPill(it) }
}
}
}
// Primary CTA: fill the gaps for this file.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
// Where the file lives.
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringRes(R.string.blossom_stored_on),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
row.servers.forEach { ServerPill(it) }
}
}
// Secondary actions.
if (row.url != null) {
DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) {
val url = row.url
scope.launch { clipboard.setText(url) }
}
DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
}
// Primary CTA: fill the gaps for this file.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
if (row.hasPresent) {
DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true }
// Secondary actions.
if (row.url != null) {
val url = row.url
DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) {
scope.launch { clipboard.setText(url) }
}
DetailAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) {
shareUrl(context, url)
}
DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
}
}
HorizontalDivider()
if (row.hasPresent) {
DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true }
row.presentServers.forEach { server ->
DetailAction(
symbol = MaterialSymbols.Delete,
label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)),
tint = MaterialTheme.colorScheme.error,
) { vm.delete(row.hash, server) }
}
HorizontalDivider()
row.presentServers.forEach { server ->
DetailAction(
symbol = MaterialSymbols.Delete,
label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)),
tint = MaterialTheme.colorScheme.error,
) { vm.delete(row.hash, server) }
}
}
}
@@ -465,31 +688,6 @@ private fun DetailAction(
}
}
@Composable
private fun DetailThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(14.dp)
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp).clip(shape),
)
} else {
Box(
modifier = Modifier.size(64.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = glyphFor(row.type),
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
private fun glyphFor(type: String?): MaterialSymbol =
when {
type?.startsWith("image/") == true -> MaterialSymbols.Image
@@ -497,6 +695,18 @@ private fun glyphFor(type: String?): MaterialSymbol =
else -> MaterialSymbols.Storage
}
private fun shareUrl(
context: Context,
url: String,
) {
val send =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
runCatching { context.startActivity(Intent.createChooser(send, null)) }
}
@Composable
private fun ServerPill(presence: ServerPresence) {
val present = presence.state == PresenceState.PRESENT
+1
View File
@@ -1672,6 +1672,7 @@
<string name="blossom_sync_channel_description">Shows progress while copying your files across your Blossom servers.</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_stored_on">Stored on</string>
<string name="blossom_file_details">File details</string>
<string name="blossom_on_all_servers">On all servers</string>
<string name="blossom_not_on_all_servers">Not on all servers</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>