⭐
Log in to mark this repository.
Android: preview video uploads
f24b73b · Gabriel Morell · 2026-09-26 04:14
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