Android: preview video uploads

f24b73b · Gabriel Morell · 2026-09-26 04:14

23 files +247 -52
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+2 −2
@@ -27,10 +27,10 @@ intent filter forwards it to the pair screen with the fields pre-filled.
27 27
28 28 - QR scan → pair → token + base URL persisted (encrypted)
29 29 - `GET /api/profile` round-trip for verification
30 −- **Home grid**: paginated upload thumbnails from `/files/<path>.thumb.<ext>` with original-file fallback
30 +- **Home grid**: paginated upload thumbnails from `/files/<path>.thumb.<ext>` with original-file fallback; videos use `.thumb.jpg` with the `.poster.jpg` frame as fallback, and carry a play badge
31 31 - **Search**: free-text + tag filter (server parses `ns:value` and `ns:*` wildcards)
32 32 - **Search**: free-text query, per-source toggles (file name / item name / description / user tags / ocr / desc / other namespaces) with full parity to the web `/search` page; matched tags surface as chips on each result card
33 −- **Detail**: full image, metadata, tag chips that pivot to a tag-filtered search, plus an "Add tags" input that writes via the new `POST /api/uploads/{id}/tags`
33 +- **Detail**: full image — or, for a video, an ExoPlayer that streams it from `/files/` looping and muted (with an unmute toggle) like the web page — metadata, tag chips that pivot to a tag-filtered search, plus an "Add tags" input that writes via the new `POST /api/uploads/{id}/tags`
34 34 - **Share-in**: receive one or many images from the share sheet, add tags, upload sequentially via multipart, with a live progress indicator
35 35 - **Share-out**: from the detail screen, download the upload to cache and hand it to another app via FileProvider
36 36 - **Pull-to-refresh + infinite scroll** on home and search
modified android/app/build.gradle.kts
+3 −0
@@ -108,6 +108,9 @@ dependencies {
108 108 implementation(libs.coil.compose)
109 109 implementation(libs.kotlinx.coroutines)
110 110
111 + implementation(libs.media3.exoplayer)
112 + implementation(libs.media3.ui)
113 +
111 114 implementation(libs.kotlinx.serialization.json)
112 115 implementation(libs.retrofit)
113 116 implementation(libs.retrofit.kotlinx.serialization)
modified android/app/src/main/java/org/neiam/dms/app/data/Models.kt
+2 −0
@@ -20,6 +20,7 @@ data class UploadSummary(
20 20 @SerialName("file_name_original") val fileNameOriginal: String,
21 21 @SerialName("item_path") val itemPath: String,
22 22 @SerialName("processing_status") val processingStatus: String,
23 + @SerialName("content_type") val contentType: String? = null,
23 24 /// Populated by /api/search; ns:value strings of the tags that triggered
24 25 /// the match. Empty for /api/uploads listings or text-only matches.
25 26 @SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
@@ -69,6 +70,7 @@ data class UploadDetail(
69 70 @SerialName("item_name") val itemName: String? = null,
70 71 @SerialName("item_description") val itemDescription: String? = null,
71 72 @SerialName("processing_status") val processingStatus: String,
73 + @SerialName("content_type") val contentType: String? = null,
72 74 @SerialName("date_uploaded") val dateUploaded: String? = null,
73 75 val tags: List<String> = emptyList(),
74 76 )
modified android/app/src/main/java/org/neiam/dms/app/data/PathHelpers.kt
+30 −0
@@ -30,3 +30,33 @@ fun thumbUrl(baseUrl: String, itemPath: String): String =
30 30
31 31 fun originalUrl(baseUrl: String, itemPath: String): String =
32 32 fileUrl(baseUrl, itemPath)
33 +
34 +private val VIDEO_EXTENSIONS = setOf("mp4", "m4v", "mov", "webm", "mkv")
35 +
36 +/**
37 + * Whether an upload is a video. Trusts `content_type` when the server sends
38 + * it; an older server doesn't, so fall back to the file's extension.
39 + */
40 +fun isVideo(contentType: String?, itemPath: String): Boolean =
41 + contentType?.startsWith("video/")
42 + ?: (itemPath.substringAfterLast('/').substringAfterLast('.', "").lowercase() in VIDEO_EXTENSIONS)
43 +
44 +/**
45 + * A video's derived artifacts are JPEGs named `.jpg`, not `.mp4` — see
46 + * `thumbnail.rs` / `poster.rs` on the server.
47 + *
48 + * sha256/aa/bb/abc.mp4 → sha256/aa/bb/abc.thumb.jpg
49 + */
50 +private fun videoDerivedPath(itemPath: String, suffix: String): String {
51 + val lastSlash = itemPath.lastIndexOf('/')
52 + val lastDot = itemPath.lastIndexOf('.')
53 + val stem = if (lastDot > lastSlash) itemPath.substring(0, lastDot) else itemPath
54 + return "$stem.$suffix.jpg"
55 +}
56 +
57 +fun videoThumbUrl(baseUrl: String, itemPath: String): String =
58 + fileUrl(baseUrl, videoDerivedPath(itemPath, "thumb"))
59 +
60 +/** The frame the `poster` job lifted out; exists before the thumbnail does. */
61 +fun videoPosterUrl(baseUrl: String, itemPath: String): String =
62 + fileUrl(baseUrl, videoDerivedPath(itemPath, "poster"))
modified android/app/src/main/java/org/neiam/dms/app/ui/DetailScreen.kt
+33 −22
@@ -26,13 +26,14 @@ import org.neiam.dms.app.data.DmsApi
26 26 import org.neiam.dms.app.data.DmsClient
27 27 import org.neiam.dms.app.data.TokenStore
28 28 import org.neiam.dms.app.data.UploadDetail
29 +import org.neiam.dms.app.data.isVideo
29 30 import org.neiam.dms.app.data.originalUrl
30 31 import org.neiam.dms.app.data.prepareShareIntent
31 32
32 33 /**
33 − * Single-upload detail. Loads `/api/uploads/{id}`, shows the image + metadata,
34 − * tag chips that pivot to a tag-filtered search, an "add tags" input, and a
35 − * share button that downloads the file and hands it to another app.
34 + * Single-upload detail. Loads `/api/uploads/{id}`, shows the image (or plays
35 + * the video) + metadata, tag chips that pivot to a tag-filtered search, an
36 + * "add tags" input, and a share button that downloads the file and hands it to another app.
36 37 */
37 38 @OptIn(ExperimentalMaterial3Api::class)
38 39 @Composable
@@ -153,25 +154,35 @@ private fun DetailBody(
153 154 .padding(16.dp),
154 155 verticalArrangement = Arrangement.spacedBy(12.dp),
155 156 ) {
156 − SubcomposeAsyncImage(
157 − model = ImageRequest.Builder(context).data(originalUrl(baseUrl, detail.itemPath)).crossfade(true).build(),
158 − contentDescription = detail.fileNameOriginal,
159 − contentScale = ContentScale.Fit,
160 − modifier = Modifier
161 − .fillMaxWidth()
162 − .heightIn(max = 360.dp)
163 − .clip(RoundedCornerShape(12.dp)),
164 − loading = {
165 − Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
166 − CircularProgressIndicator()
167 − }
168 − },
169 − error = {
170 − Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
171 − Text("Could not load image")
172 − }
173 − },
174 − )
157 + if (isVideo(detail.contentType, detail.itemPath)) {
158 + VideoPlayer(
159 + url = originalUrl(baseUrl, detail.itemPath),
160 + modifier = Modifier
161 + .fillMaxWidth()
162 + .heightIn(max = 360.dp)
163 + .clip(RoundedCornerShape(12.dp)),
164 + )
165 + } else {
166 + SubcomposeAsyncImage(
167 + model = ImageRequest.Builder(context).data(originalUrl(baseUrl, detail.itemPath)).crossfade(true).build(),
168 + contentDescription = detail.fileNameOriginal,
169 + contentScale = ContentScale.Fit,
170 + modifier = Modifier
171 + .fillMaxWidth()
172 + .heightIn(max = 360.dp)
173 + .clip(RoundedCornerShape(12.dp)),
174 + loading = {
175 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
176 + CircularProgressIndicator()
177 + }
178 + },
179 + error = {
180 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
181 + Text("Could not load image")
182 + }
183 + },
184 + )
185 + }
175 186
176 187 Card(modifier = Modifier.fillMaxWidth()) {
177 188 Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
modified android/app/src/main/java/org/neiam/dms/app/ui/UploadCard.kt
+54 −28
@@ -1,14 +1,19 @@
1 1 package org.neiam.dms.app.ui
2 2
3 +import androidx.compose.foundation.background
3 4 import androidx.compose.foundation.layout.*
4 5 import androidx.compose.foundation.layout.ExperimentalLayoutApi
5 6 import androidx.compose.foundation.layout.FlowRow
7 +import androidx.compose.foundation.shape.CircleShape
6 8 import androidx.compose.foundation.shape.RoundedCornerShape
9 +import androidx.compose.material.icons.Icons
10 +import androidx.compose.material.icons.filled.PlayArrow
7 11 import androidx.compose.material3.*
8 12 import androidx.compose.runtime.Composable
9 13 import androidx.compose.ui.Alignment
10 14 import androidx.compose.ui.Modifier
11 15 import androidx.compose.ui.draw.clip
16 +import androidx.compose.ui.graphics.Color
12 17 import androidx.compose.ui.layout.ContentScale
13 18 import androidx.compose.ui.platform.LocalContext
14 19 import androidx.compose.ui.text.style.TextOverflow
@@ -16,8 +21,11 @@ import androidx.compose.ui.unit.dp
16 21 import coil.compose.SubcomposeAsyncImage
17 22 import coil.request.ImageRequest
18 23 import org.neiam.dms.app.data.UploadSummary
24 +import org.neiam.dms.app.data.isVideo
19 25 import org.neiam.dms.app.data.originalUrl
20 26 import org.neiam.dms.app.data.thumbUrl
27 +import org.neiam.dms.app.data.videoPosterUrl
28 +import org.neiam.dms.app.data.videoThumbUrl
21 29
22 30 /**
23 31 * Square thumbnail card. Shared between the home grid and the search
@@ -27,6 +35,9 @@ import org.neiam.dms.app.data.thumbUrl
27 35 * pending or failed) it falls back to the original; if that also fails the
28 36 * card shows the processing status as a label.
29 37 *
38 + * A video's fallback is its extracted poster frame rather than the original,
39 + * which Coil can't decode; the card carries a play badge so it reads as one.
40 + *
30 41 * `upload.matchedTags` is populated only by `/api/search` responses — when
31 42 * non-empty the chips render under the filename so the user can see which
32 43 * tag(s) triggered the match (mirrors the web search page).
@@ -40,8 +51,9 @@ fun UploadCard(
40 51 modifier: Modifier = Modifier,
41 52 ) {
42 53 val context = LocalContext.current
43 − val thumb = thumbUrl(baseUrl, upload.itemPath)
44 − val original = originalUrl(baseUrl, upload.itemPath)
54 + val video = isVideo(upload.contentType, upload.itemPath)
55 + val thumb = if (video) videoThumbUrl(baseUrl, upload.itemPath) else thumbUrl(baseUrl, upload.itemPath)
56 + val fallback = if (video) videoPosterUrl(baseUrl, upload.itemPath) else originalUrl(baseUrl, upload.itemPath)
45 57
46 58 Card(
47 59 onClick = onClick ?: {},
@@ -49,33 +61,47 @@ fun UploadCard(
49 61 modifier = modifier.fillMaxWidth(),
50 62 ) {
51 63 Column {
52 − SubcomposeAsyncImage(
53 − model = ImageRequest.Builder(context).data(thumb).crossfade(true).build(),
54 − contentDescription = upload.fileNameOriginal,
55 − contentScale = ContentScale.Crop,
56 − modifier = Modifier
57 − .fillMaxWidth()
58 − .aspectRatio(1f)
59 − .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
60 − loading = {
61 − Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
62 − CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
63 − }
64 − },
65 − error = {
66 − SubcomposeAsyncImage(
67 − model = ImageRequest.Builder(context).data(original).crossfade(true).build(),
68 − contentDescription = upload.fileNameOriginal,
69 − contentScale = ContentScale.Crop,
70 − modifier = Modifier.fillMaxSize(),
71 − error = {
72 − Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
73 − Text(upload.processingStatus, style = MaterialTheme.typography.labelSmall)
74 − }
75 − },
64 + Box {
65 + SubcomposeAsyncImage(
66 + model = ImageRequest.Builder(context).data(thumb).crossfade(true).build(),
67 + contentDescription = upload.fileNameOriginal,
68 + contentScale = ContentScale.Crop,
69 + modifier = Modifier
70 + .fillMaxWidth()
71 + .aspectRatio(1f)
72 + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
73 + loading = {
74 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
75 + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
76 + }
77 + },
78 + error = {
79 + SubcomposeAsyncImage(
80 + model = ImageRequest.Builder(context).data(fallback).crossfade(true).build(),
81 + contentDescription = upload.fileNameOriginal,
82 + contentScale = ContentScale.Crop,
83 + modifier = Modifier.fillMaxSize(),
84 + error = {
85 + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
86 + Text(upload.processingStatus, style = MaterialTheme.typography.labelSmall)
87 + }
88 + },
89 + )
90 + },
91 + )
92 + if (video) {
93 + Icon(
94 + Icons.Default.PlayArrow,
95 + contentDescription = "Video",
96 + tint = Color.White,
97 + modifier = Modifier
98 + .align(Alignment.Center)
99 + .size(36.dp)
100 + .background(Color.Black.copy(alpha = 0.5f), CircleShape)
101 + .padding(4.dp),
76 102 )
77 − },
78 − )
103 + }
104 + }
79 105 Column(modifier = Modifier.padding(8.dp)) {
80 106 Text(
81 107 upload.fileNameOriginal,
added android/app/src/main/java/org/neiam/dms/app/ui/VideoPlayer.kt
+112 −0
@@ -0,0 +1,112 @@
1 +package org.neiam.dms.app.ui
2 +
3 +import androidx.annotation.OptIn
4 +import androidx.compose.foundation.background
5 +import androidx.compose.foundation.layout.*
6 +import androidx.compose.foundation.shape.CircleShape
7 +import androidx.compose.material.icons.Icons
8 +import androidx.compose.material.icons.automirrored.filled.VolumeOff
9 +import androidx.compose.material.icons.automirrored.filled.VolumeUp
10 +import androidx.compose.material3.Icon
11 +import androidx.compose.material3.IconButton
12 +import androidx.compose.runtime.*
13 +import androidx.compose.ui.Alignment
14 +import androidx.compose.ui.Modifier
15 +import androidx.compose.ui.graphics.Color
16 +import androidx.compose.ui.platform.LocalContext
17 +import androidx.compose.ui.platform.LocalLifecycleOwner
18 +import androidx.compose.ui.unit.dp
19 +import androidx.compose.ui.viewinterop.AndroidView
20 +import androidx.lifecycle.Lifecycle
21 +import androidx.lifecycle.LifecycleEventObserver
22 +import androidx.media3.common.MediaItem
23 +import androidx.media3.common.Player
24 +import androidx.media3.common.VideoSize
25 +import androidx.media3.common.util.UnstableApi
26 +import androidx.media3.exoplayer.ExoPlayer
27 +import androidx.media3.ui.AspectRatioFrameLayout
28 +import androidx.media3.ui.PlayerView
29 +
30 +/**
31 + * Streams a video straight from `/files/` (unauthenticated, like the image
32 + * view) with ExoPlayer.
33 + *
34 + * Mirrors the web detail page's `<video loop muted>`: most of these are
35 + * silent converted GIFs, so it starts playing, loops, and starts muted — with
36 + * a toggle, since some do have sound. Pauses when the app is backgrounded and
37 + * releases the player when the screen goes away.
38 + */
39 +@OptIn(UnstableApi::class)
40 +@Composable
41 +fun VideoPlayer(url: String, modifier: Modifier = Modifier) {
42 + val context = LocalContext.current
43 + val lifecycleOwner = LocalLifecycleOwner.current
44 + var muted by remember { mutableStateOf(true) }
45 + // 16:9 until the first frame says otherwise, so the box doesn't jump from 0.
46 + var aspect by remember { mutableFloatStateOf(16f / 9f) }
47 +
48 + val exoPlayer = remember(url) {
49 + ExoPlayer.Builder(context).build().apply {
50 + setMediaItem(MediaItem.fromUri(url))
51 + repeatMode = Player.REPEAT_MODE_ONE
52 + volume = 0f
53 + playWhenReady = true
54 + prepare()
55 + }
56 + }
57 +
58 + DisposableEffect(exoPlayer) {
59 + val sizeListener = object : Player.Listener {
60 + override fun onVideoSizeChanged(videoSize: VideoSize) {
61 + if (videoSize.width > 0 && videoSize.height > 0) {
62 + aspect = videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
63 + }
64 + }
65 + }
66 + exoPlayer.addListener(sizeListener)
67 + onDispose {
68 + exoPlayer.removeListener(sizeListener)
69 + exoPlayer.release()
70 + }
71 + }
72 +
73 + DisposableEffect(exoPlayer, lifecycleOwner) {
74 + val lifecycleObserver = LifecycleEventObserver { _, event ->
75 + if (event == Lifecycle.Event.ON_STOP) exoPlayer.pause()
76 + }
77 + lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
78 + onDispose { lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) }
79 + }
80 +
81 + LaunchedEffect(exoPlayer, muted) { exoPlayer.volume = if (muted) 0f else 1f }
82 +
83 + Box(modifier) {
84 + AndroidView(
85 + factory = { ctx ->
86 + PlayerView(ctx).apply {
87 + this.player = exoPlayer
88 + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
89 + setShowNextButton(false)
90 + setShowPreviousButton(false)
91 + }
92 + },
93 + update = { it.player = exoPlayer },
94 + modifier = Modifier
95 + .fillMaxWidth()
96 + .aspectRatio(aspect),
97 + )
98 + IconButton(
99 + onClick = { muted = !muted },
100 + modifier = Modifier
101 + .align(Alignment.TopEnd)
102 + .padding(8.dp)
103 + .background(Color.Black.copy(alpha = 0.5f), CircleShape),
104 + ) {
105 + Icon(
106 + if (muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
107 + contentDescription = if (muted) "Unmute" else "Mute",
108 + tint = Color.White,
109 + )
110 + }
111 + }
112 +}
modified android/gradle/libs.versions.toml
+5 −0
@@ -16,6 +16,8 @@ retrofit-kotlinx-serialization = "1.0.0"
16 16 security-crypto = "1.1.0-alpha06"
17 17 zxing-embedded = "4.3.0"
18 18 coroutines = "1.8.1"
19 +# 1.5 wants compileSdk 35; stay on the last line that builds against 34.
20 +media3 = "1.4.1"
19 21
20 22 [libraries]
21 23 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
@@ -41,6 +43,9 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
41 43 kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
42 44 kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
43 45
46 +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
47 +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
48 +
44 49 zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxing-embedded" }
45 50
46 51 [plugins]
modified src/handlers/uploads.rs
+6 −0
@@ -399,6 +399,9 @@ pub struct UploadJson {
399 399 pub file_name_original: String,
400 400 pub item_path: String,
401 401 pub processing_status: String,
402 + /// Lets a client tell a video from an image without guessing from the
403 + /// extension: a video's thumbnail is `.thumb.jpg`, not `.thumb.mp4`.
404 + pub content_type: Option<String>,
402 405 }
403 406
404 407 pub async fn api_list_uploads(
@@ -417,6 +420,7 @@ pub async fn api_list_uploads(
417 420 file_name_original: u.file_name_original,
418 421 item_path: u.item_path,
419 422 processing_status: u.processing_status,
423 + content_type: u.content_type,
420 424 })
421 425 .collect();
422 426
@@ -588,6 +592,7 @@ pub async fn api_show_upload(
588 592 "item_name": upload.item_name,
589 593 "item_description": upload.item_description,
590 594 "processing_status": upload.processing_status,
595 + "content_type": upload.content_type,
591 596 "date_uploaded": upload.date_uploaded,
592 597 "tags": tag_strings,
593 598 })))
@@ -1867,6 +1872,7 @@ pub async fn api_search_uploads(
1867 1872 "file_name_original": u.file_name_original,
1868 1873 "item_path": u.item_path,
1869 1874 "processing_status": u.processing_status,
1875 + "content_type": u.content_type,
1870 1876 "matched_tags": matched,
1871 1877 })
1872 1878 })

Parents: 248c3c7