diff --git a/ROADMAP.md b/ROADMAP.md
index a55a5f8e..f15a8a68 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -208,6 +208,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": `ImportTaskState.PolicyName` baked at creation (resolved best-effort via `StoragePolicyClient`), summary emits `dst_policy_name` so admin views of other users' tasks work without a policy lookup; `policyOptionCache` retyped to `StoragePolicyBrief[]` and populated from `getAllowedPolicies()` at session init as fallback for legacy tasks
- [x] #199 (upstream #3584) — markdown editor lag: root cause was per-keystroke React re-renders (changedValue state fed back into the editor's initial-markdown prop) re-running every plugin's `update()` hook (RealmWithPlugins has a dep-less effect). `MarkdownEditor` is now `memo`'d with a `useMemo`'d plugins array + stable `translation`; `MarkdownViewer` keeps edits in a ref (read at save) and passes the immutable loaded content — zero re-renders per keystroke
- [x] #200 (upstream #3586) — SIGHUP "crash": the upstream log was a clean signal-driven shutdown (SIGHUP was registered in `signal.Notify`), triggered when the reporter's terminal/SSH session closed. `signal.Ignore(syscall.SIGHUP)` now — default disposition would terminate the process; verified live: server survives `kill -HUP` (HTTP stays 200) and still shuts down cleanly on SIGTERM
+- [x] #89 (upstream #3277) — Android Motion Photo preview: `util/motionPhoto.ts` extracts the MP4 appended at EOF of MicroVideo/MotionPhoto JPEGs — XMP `GCamera:MicroVideoOffset` or `Container:Directory` `video/mp4` `Item:Length`, fetched via Range requests (128 KiB head + video tail only), `ftyp` sanity-checked; `Photo.tsx` feeds the blob URL into the same LivePhotosKit player used for iOS live photos — badge + press-to-play for free; degrades silently on CORS/fetch failure
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
- [x] Download URL shuffling (#173) — `download_cdn_shuffle` distributes generated download URLs randomly across the site URL + `download_cdn_routes` endpoints (`setting.DownloadURLBase`, honors `UseFirstSiteUrl`); covers entity downloads, archive sessions, and redirect-type direct links; manual route picker hidden client-side while active
@@ -231,13 +232,14 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo.
- Status: (1) notifications already per-OS (`win32_notif` / `notify_rust` / `mac_notification_sys`) — no abstraction needed; (2) hydration abstracted via `drive/placeholder` cfg swap — `cfapi` on Windows, `placeholder_non_windows` full-sync adapter elsewhere (FUSE / File Provider still open); (3) CI matrix builds + tests all 3 OSes; (4) packaging: `desktop-release.yml` on `desktop-v*` tags ships .msi/.exe (Windows), .dmg (macOS), .deb/.AppImage (Linux) — MSIX deferred (needs store signing).
- Verified on Linux: `cargo test --workspace` green (49 tests), `cargo tauri build` produces working .deb + .AppImage.
- Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine).
+- [x] #167 (upstream desktop#49) — online-only thumbnails missing in Explorer: root cause was a client/server contract mismatch — the CE `/file/thumb` response carries only `url`/`expires` while the `cloudreve-api` model required `obfuscated`, failing deserialization on every thumbnail request (`E_FAIL` to Explorer; hydrated files were unaffected since Windows thumbs them locally). `obfuscated` is now `#[serde(default)]`; the decode path still runs when a server emits the flag
## 7. Phase E — Android app (native, no iOS)
Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
-- **Core features**: browse/download/upload files, share links, camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager
+- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files, local sync folder via SAF/WorkManager
- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events
- **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
diff --git a/android/README.md b/android/README.md
index e7ca7412..d15d3973 100644
--- a/android/README.md
+++ b/android/README.md
@@ -27,9 +27,15 @@ CI runs `assembleDebug` on every PR.
upload-session flow (local + presigned remote policies)
- Thumbnails via `/file/thumb` (Coil, lazily resolved per row)
- Share-link creation (`PUT /share`), link copied to clipboard
+- Full-text search (`GET /file/search`): query bar in the files screen,
+ offset pagination, result rows show parent path + content snippet;
+ folder hits navigate into place
+- Camera auto-upload: periodic WorkManager sync of new MediaStore
+ photos/videos to a configurable remote folder, Wi-Fi-only constraint,
+ media-permission gated toggle, synced/failed ID dedup, manual
+ "sync now" — settings dialog in the files top bar
## Planned next
-- Camera auto-upload, offline-favorite files, DocumentsProvider
-- Search
+- Offline-favorite files, DocumentsProvider
- No iOS. Ever.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f62ccfc0..7ac9fbc7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,11 @@
+
+
+
>
+ @GET("api/v4/file/search")
+ suspend fun searchFiles(
+ @Query("query") query: String,
+ @Query("offset") offset: Int = 0,
+ ): Response>
+
@PUT("api/v4/share")
suspend fun createShare(@Body request: ShareCreateRequest): Response>
}
diff --git a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
index d4af8e5b..5e34ee2c 100644
--- a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
+++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt
@@ -172,6 +172,18 @@ data class ShareCreateRequest(
@SerialName("upload_only") val uploadOnly: Boolean = false,
)
+@Serializable
+data class SearchHit(
+ val file: FileObject = FileObject(),
+ val content: String = "",
+)
+
+@Serializable
+data class SearchResponse(
+ val hits: List = emptyList(),
+ val total: Long = 0,
+)
+
@Serializable
data class SiteConfig(
val authn: Boolean = false,
diff --git a/android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt b/android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt
new file mode 100644
index 00000000..36ab0d2f
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt
@@ -0,0 +1,108 @@
+package org.cloudreve.android.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import org.cloudreve.android.util.CrUri
+
+private val Context.cameraUploadStore by preferencesDataStore(name = "camera_upload")
+
+/**
+ * Camera auto-upload preferences plus the incremental-sync bookkeeping
+ * (which MediaStore items were already uploaded or permanently failed).
+ * ID sets are bounded so storage stays small; evicted entries may rarely
+ * cause a duplicate upload of an old item.
+ */
+class CameraUploadSettings(private val context: Context) {
+
+ private object Keys {
+ val enabled = booleanPreferencesKey("enabled")
+ val remoteFolder = stringPreferencesKey("remote_folder")
+ val wifiOnly = booleanPreferencesKey("wifi_only")
+ val includeVideos = booleanPreferencesKey("include_videos")
+ val syncedIds = stringSetPreferencesKey("synced_ids")
+ val failedIds = stringSetPreferencesKey("failed_ids")
+ val lastSyncTs = longPreferencesKey("last_sync_ts")
+ val lastSyncAt = longPreferencesKey("last_sync_at")
+ }
+
+ data class Snapshot(
+ val enabled: Boolean = false,
+ val remoteFolder: String = DEFAULT_FOLDER,
+ val wifiOnly: Boolean = true,
+ val includeVideos: Boolean = true,
+ val lastSyncAt: Long = 0L,
+ )
+
+ val snapshot: Flow = context.cameraUploadStore.data.map {
+ Snapshot(
+ enabled = it[Keys.enabled] ?: false,
+ remoteFolder = it[Keys.remoteFolder] ?: DEFAULT_FOLDER,
+ wifiOnly = it[Keys.wifiOnly] ?: true,
+ includeVideos = it[Keys.includeVideos] ?: true,
+ lastSyncAt = it[Keys.lastSyncAt] ?: 0L,
+ )
+ }
+
+ suspend fun snapshotNow(): Snapshot = snapshot.first()
+
+ suspend fun setEnabled(enabled: Boolean) {
+ context.cameraUploadStore.edit { it[Keys.enabled] = enabled }
+ }
+
+ suspend fun setRemoteFolder(folder: String) {
+ context.cameraUploadStore.edit { it[Keys.remoteFolder] = folder.trim() }
+ }
+
+ suspend fun setWifiOnly(wifiOnly: Boolean) {
+ context.cameraUploadStore.edit { it[Keys.wifiOnly] = wifiOnly }
+ }
+
+ suspend fun setIncludeVideos(include: Boolean) {
+ context.cameraUploadStore.edit { it[Keys.includeVideos] = include }
+ }
+
+ suspend fun syncedIdsNow(): Set =
+ context.cameraUploadStore.data.first()[Keys.syncedIds] ?: emptySet()
+
+ suspend fun failedIdsNow(): Set =
+ context.cameraUploadStore.data.first()[Keys.failedIds] ?: emptySet()
+
+ suspend fun lastSyncTsNow(): Long =
+ context.cameraUploadStore.data.first()[Keys.lastSyncTs] ?: 0L
+
+ suspend fun markSynced(id: String, dateAddedSec: Long) {
+ context.cameraUploadStore.edit { prefs ->
+ val synced = (prefs[Keys.syncedIds] ?: emptySet()).toMutableSet()
+ synced.add(id)
+ prefs[Keys.syncedIds] = synced.bounded()
+ if (dateAddedSec > (prefs[Keys.lastSyncTs] ?: 0L)) {
+ prefs[Keys.lastSyncTs] = dateAddedSec
+ }
+ prefs[Keys.lastSyncAt] = System.currentTimeMillis()
+ }
+ }
+
+ suspend fun markFailed(id: String) {
+ context.cameraUploadStore.edit { prefs ->
+ val failed = (prefs[Keys.failedIds] ?: emptySet()).toMutableSet()
+ failed.add(id)
+ prefs[Keys.failedIds] = failed.bounded()
+ }
+ }
+
+ private fun Set.bounded(): Set =
+ if (size <= MAX_TRACKED_IDS) this else this.drop(size - MAX_TRACKED_IDS).toSet()
+
+ companion object {
+ const val DEFAULT_FOLDER = "${CrUri.MY_PREFIX}/Photos"
+ private const val MAX_TRACKED_IDS = 2000
+ }
+}
diff --git a/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt
index 3e7b25d1..52831217 100644
--- a/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt
+++ b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt
@@ -10,6 +10,7 @@ import org.cloudreve.android.api.FileObject
import org.cloudreve.android.api.FileUrlRequest
import org.cloudreve.android.api.ListFileResponse
import org.cloudreve.android.api.RenameFileRequest
+import org.cloudreve.android.api.SearchResponse
import org.cloudreve.android.api.ShareCreateRequest
import org.cloudreve.android.api.UploadSessionResponse
import org.cloudreve.android.util.CrUri
@@ -58,6 +59,9 @@ class FileRepository(private val api: ApiClient) {
return "$base/s/$id"
}
+ suspend fun search(query: String, offset: Int = 0): SearchResponse =
+ api.service().searchFiles(query = query, offset = offset).unwrap()
+
suspend fun mkdir(uri: String) {
api.service().createFile(CreateFileRequest(uri = uri, type = "folder")).unwrapEmpty()
}
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt
index 21a18228..72e9d47e 100644
--- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt
+++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt
@@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
@@ -26,8 +28,11 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
@@ -45,6 +50,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -61,13 +67,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import kotlinx.coroutines.launch
+import org.cloudreve.android.CloudreveApp
import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.util.CrUri
+import org.cloudreve.android.work.CameraUploadWorker
import org.cloudreve.android.work.UploadWorker
import java.text.DecimalFormat
@@ -86,6 +98,12 @@ fun FilesScreen(
var mkdirOpen by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf(null) }
var deleteTarget by remember { mutableStateOf(null) }
+ var searchOpen by remember { mutableStateOf(false) }
+ var searchText by remember { mutableStateOf("") }
+ var cameraSettingsOpen by remember { mutableStateOf(false) }
+ val searchFocus = remember { FocusRequester() }
+
+ val inSearch = state.searchQuery != null
val uploadLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
@@ -109,45 +127,92 @@ fun FilesScreen(
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { last ->
- if (last != null && last >= state.files.size - 5) viewModel.loadMore()
+ if (last == null) return@collect
+ if (inSearch) {
+ if (last >= state.searchResults.size - 5) viewModel.searchMore()
+ } else if (last >= state.files.size - 5) {
+ viewModel.loadMore()
+ }
}
}
- BackHandler(enabled = !CrUri.isRoot(state.currentUri)) {
- viewModel.navigateUp()
+ LaunchedEffect(searchOpen) {
+ if (searchOpen) searchFocus.requestFocus()
+ }
+
+ fun closeSearch() {
+ viewModel.clearSearch()
+ searchOpen = false
+ searchText = ""
+ }
+
+ BackHandler(enabled = inSearch || !CrUri.isRoot(state.currentUri)) {
+ if (inSearch) closeSearch() else viewModel.navigateUp()
}
Scaffold(
topBar = {
TopAppBar(
title = {
- Column {
- Text("Files")
- Text(
- pathLabel(state.currentUri),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ if (inSearch || searchOpen) {
+ OutlinedTextField(
+ value = searchText,
+ onValueChange = { searchText = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(searchFocus),
+ placeholder = { Text("Search files") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(
+ onSearch = { viewModel.search(searchText) },
+ ),
)
+ } else {
+ Column {
+ Text("Files")
+ Text(
+ pathLabel(state.currentUri),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
},
navigationIcon = {
- if (!CrUri.isRoot(state.currentUri)) {
+ if (inSearch || searchOpen) {
+ IconButton(onClick = { closeSearch() }) {
+ Icon(Icons.Default.Close, contentDescription = "Close search")
+ }
+ } else if (!CrUri.isRoot(state.currentUri)) {
IconButton(onClick = { viewModel.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up")
}
}
},
actions = {
- IconButton(onClick = { mkdirOpen = true }) {
- Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
- }
- IconButton(onClick = { viewModel.refresh() }) {
- Icon(Icons.Default.Refresh, contentDescription = "Refresh")
- }
- IconButton(onClick = onSignOut) {
- Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out")
+ if (!inSearch && !searchOpen) {
+ IconButton(onClick = { searchOpen = true }) {
+ Icon(Icons.Default.Search, contentDescription = "Search")
+ }
+ IconButton(onClick = { cameraSettingsOpen = true }) {
+ Icon(Icons.Default.Settings, contentDescription = "Camera backup")
+ }
+ IconButton(onClick = { mkdirOpen = true }) {
+ Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
+ }
+ IconButton(onClick = { viewModel.refresh() }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ IconButton(onClick = onSignOut) {
+ Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out")
+ }
+ } else if (!inSearch) {
+ IconButton(onClick = { viewModel.search(searchText) }) {
+ Icon(Icons.Default.Search, contentDescription = "Search")
+ }
}
},
)
@@ -167,6 +232,24 @@ fun FilesScreen(
.padding(padding),
) {
when {
+ inSearch -> {
+ SearchResults(
+ state = state,
+ viewModel = viewModel,
+ listState = listState,
+ onOpenFolder = { file ->
+ closeSearch()
+ viewModel.navigateTo(file)
+ },
+ onOpenFile = { file ->
+ scope.launch {
+ runCatching { viewModel.downloadToCache(context, file) }
+ .onSuccess { local -> openFile(context, local) }
+ .onFailure { snackbar.showSnackbar(it.message ?: "Download failed") }
+ }
+ },
+ )
+ }
state.loading && state.files.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
@@ -285,6 +368,10 @@ fun FilesScreen(
},
)
}
+
+ if (cameraSettingsOpen) {
+ CameraUploadDialog(onDismiss = { cameraSettingsOpen = false })
+ }
}
private fun pathLabel(uri: String): String {
@@ -292,6 +379,212 @@ private fun pathLabel(uri: String): String {
return if (path.isEmpty()) "My files" else "My files / ${java.net.URLDecoder.decode(path, "UTF-8")}"
}
+@Composable
+private fun SearchResults(
+ state: FilesUiState,
+ viewModel: FilesViewModel,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ onOpenFolder: (FileObject) -> Unit,
+ onOpenFile: (FileObject) -> Unit,
+) {
+ when {
+ state.searchLoading -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ state.searchError != null -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(state.searchError ?: "Search failed", color = MaterialTheme.colorScheme.error)
+ TextButton(onClick = { viewModel.search(state.searchQuery ?: "") }) {
+ Text("Retry")
+ }
+ }
+ }
+ }
+ state.searchResults.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ "No results for \"${state.searchQuery}\"",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ else -> {
+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
+ items(state.searchResults, key = { it.file.id + it.file.path }) { hit ->
+ val file = hit.file
+ ListItem(
+ modifier = Modifier.clickable {
+ if (file.isFolder) onOpenFolder(file) else onOpenFile(file)
+ },
+ headlineContent = {
+ Text(file.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ supportingContent = {
+ Column {
+ Text(
+ pathLabel(CrUri.parent(file.path)),
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (hit.content.isNotBlank()) {
+ Text(
+ hit.content.trim(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ },
+ leadingContent = {
+ Icon(
+ if (file.isFolder) Icons.Default.Folder
+ else Icons.AutoMirrored.Filled.InsertDriveFile,
+ contentDescription = null,
+ tint = if (file.isFolder) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ )
+ HorizontalDivider()
+ }
+ if (state.searchLoadingMore) {
+ item {
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) { CircularProgressIndicator() }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CameraUploadDialog(onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ val app = context.applicationContext as CloudreveApp
+ val settings = app.cameraUploadSettings
+ val scope = rememberCoroutineScope()
+ val snap by settings.snapshot.collectAsState(initial = null)
+ var folderText by remember(snap?.remoteFolder) {
+ mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER)
+ }
+
+ val permLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { grants ->
+ if (grants.values.all { it }) {
+ scope.launch {
+ settings.setRemoteFolder(folderText)
+ settings.setEnabled(true)
+ CameraUploadWorker.apply(context, snap?.wifiOnly ?: true)
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Camera backup") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Back up new photos & videos", Modifier.weight(1f))
+ Switch(
+ checked = snap?.enabled == true,
+ onCheckedChange = { want ->
+ if (want) {
+ permLauncher.launch(mediaPermissions())
+ } else {
+ scope.launch {
+ settings.setEnabled(false)
+ CameraUploadWorker.cancel(context)
+ }
+ }
+ },
+ )
+ }
+ OutlinedTextField(
+ value = folderText,
+ onValueChange = { folderText = it },
+ label = { Text("Remote folder") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Wi-Fi only", Modifier.weight(1f))
+ Switch(
+ checked = snap?.wifiOnly ?: true,
+ onCheckedChange = { wifiOnly ->
+ scope.launch {
+ settings.setWifiOnly(wifiOnly)
+ if (snap?.enabled == true) {
+ CameraUploadWorker.apply(context, wifiOnly)
+ }
+ }
+ },
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Include videos", Modifier.weight(1f))
+ Switch(
+ checked = snap?.includeVideos ?: true,
+ onCheckedChange = { scope.launch { settings.setIncludeVideos(it) } },
+ )
+ }
+ snap?.lastSyncAt?.takeIf { it > 0 }?.let {
+ Text(
+ "Last backup: " + java.text.DateFormat.getDateTimeInstance(
+ java.text.DateFormat.SHORT, java.text.DateFormat.SHORT,
+ ).format(java.util.Date(it)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ scope.launch {
+ settings.setRemoteFolder(folderText)
+ CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true)
+ }
+ }) { Text("Sync now") }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ scope.launch { settings.setRemoteFolder(folderText) }
+ onDismiss()
+ }) { Text("Done") }
+ },
+ )
+}
+
+private fun mediaPermissions(): Array =
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ arrayOf(
+ android.Manifest.permission.READ_MEDIA_IMAGES,
+ android.Manifest.permission.READ_MEDIA_VIDEO,
+ )
+ } else {
+ arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+
private fun openFile(context: android.content.Context, local: java.io.File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local)
val intent = Intent(Intent.ACTION_VIEW).apply {
diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt
index ef4f0bb3..2a673c7c 100644
--- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt
+++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.api.SearchHit
import org.cloudreve.android.data.FileRepository
import org.cloudreve.android.util.CrUri
import java.io.File
@@ -21,6 +22,12 @@ data class FilesUiState(
val nextToken: String? = null,
val error: String? = null,
val snackbar: String? = null,
+ val searchQuery: String? = null,
+ val searchResults: List = emptyList(),
+ val searchTotal: Long = 0,
+ val searchLoading: Boolean = false,
+ val searchLoadingMore: Boolean = false,
+ val searchError: String? = null,
)
class FilesViewModel(private val repo: FileRepository) : ViewModel() {
@@ -78,6 +85,59 @@ class FilesViewModel(private val repo: FileRepository) : ViewModel() {
return true
}
+ fun search(query: String) {
+ val q = query.trim()
+ if (q.isEmpty()) return
+ _state.value = _state.value.copy(
+ searchQuery = q,
+ searchResults = emptyList(),
+ searchTotal = 0,
+ searchLoading = true,
+ searchError = null,
+ )
+ viewModelScope.launch {
+ try {
+ val res = repo.search(q, 0)
+ _state.value = _state.value.copy(
+ searchLoading = false,
+ searchResults = res.hits,
+ searchTotal = res.total,
+ )
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(searchLoading = false, searchError = e.message)
+ }
+ }
+ }
+
+ fun searchMore() {
+ val s = _state.value
+ val q = s.searchQuery ?: return
+ if (s.searchLoadingMore || s.searchResults.size >= s.searchTotal) return
+ _state.value = s.copy(searchLoadingMore = true)
+ viewModelScope.launch {
+ try {
+ val res = repo.search(q, s.searchResults.size)
+ _state.value = _state.value.copy(
+ searchLoadingMore = false,
+ searchResults = _state.value.searchResults + res.hits,
+ )
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(searchLoadingMore = false, snackbar = e.message)
+ }
+ }
+ }
+
+ fun clearSearch() {
+ _state.value = _state.value.copy(
+ searchQuery = null,
+ searchResults = emptyList(),
+ searchTotal = 0,
+ searchLoading = false,
+ searchLoadingMore = false,
+ searchError = null,
+ )
+ }
+
fun mkdir(name: String) {
if (name.isBlank()) return
viewModelScope.launch {
diff --git a/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt
new file mode 100644
index 00000000..44ab6e28
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt
@@ -0,0 +1,195 @@
+package org.cloudreve.android.work
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.ContentUris
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.provider.MediaStore
+import androidx.core.app.NotificationCompat
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.ForegroundInfo
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import org.cloudreve.android.CloudreveApp
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * Periodically scans MediaStore for photos/videos captured since the last
+ * sync and uploads them to the configured Cloudreve folder. A bounded set of
+ * synced/failed MediaStore IDs prevents re-uploads; the date-added watermark
+ * bounds each scan. Runs unmetered-only when the user chose Wi-Fi only.
+ */
+class CameraUploadWorker(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val app = applicationContext as CloudreveApp
+ val settings = app.cameraUploadSettings
+ val cfg = settings.snapshotNow()
+ if (!cfg.enabled) return Result.success()
+
+ createChannel()
+
+ val synced = settings.syncedIdsNow().toMutableSet()
+ val failed = settings.failedIdsNow()
+ val lastTs = settings.lastSyncTsNow()
+
+ val items = queryNewMedia(lastTs, cfg.includeVideos)
+ .filter { it.id !in synced && it.id !in failed }
+ .take(MAX_PER_RUN)
+
+ items.forEachIndexed { i, item ->
+ setForeground(progressInfo("Backing up ${item.name}", i, items.size))
+ try {
+ val tmp = stageToCache(item)
+ try {
+ app.fileRepository.uploadFile(tmp, cfg.remoteFolder) { _, _ -> }
+ settings.markSynced(item.id, item.dateAddedSec)
+ } finally {
+ tmp.delete()
+ }
+ } catch (_: Exception) {
+ // Permanently skipped: retried MediaStore rows that keep
+ // failing would otherwise block every later photo.
+ settings.markFailed(item.id)
+ }
+ }
+ return Result.success()
+ }
+
+ private data class MediaItem(
+ val id: String,
+ val contentUri: android.net.Uri,
+ val name: String,
+ val dateAddedSec: Long,
+ )
+
+ private fun queryNewMedia(sinceSec: Long, includeVideos: Boolean): List {
+ val out = mutableListOf()
+ val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ val projection = arrayOf(
+ MediaStore.Files.FileColumns._ID,
+ MediaStore.Files.FileColumns.DISPLAY_NAME,
+ MediaStore.Files.FileColumns.DATE_ADDED,
+ MediaStore.Files.FileColumns.MEDIA_TYPE,
+ )
+ val mediaTypes = buildList {
+ add(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE)
+ if (includeVideos) add(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
+ }
+ val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND " +
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (${mediaTypes.joinToString(",") { "?" }})"
+ val args = (listOf(sinceSec) + mediaTypes).map { it.toString() }.toTypedArray()
+ val order = "${MediaStore.Files.FileColumns.DATE_ADDED} ASC"
+
+ applicationContext.contentResolver.query(
+ collection, projection, selection, args, order,
+ )?.use { cursor ->
+ val idIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ val nameIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
+ val dateIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idIdx)
+ out.add(
+ MediaItem(
+ id = id.toString(),
+ contentUri = ContentUris.withAppendedId(collection, id),
+ name = cursor.getString(nameIdx) ?: "media_$id",
+ dateAddedSec = cursor.getLong(dateIdx),
+ )
+ )
+ }
+ }
+ return out
+ }
+
+ private fun stageToCache(item: MediaItem): File {
+ val dir = File(applicationContext.cacheDir, "camera_staging").apply { mkdirs() }
+ val tmp = File(dir, item.name)
+ applicationContext.contentResolver.openInputStream(item.contentUri)!!.use { input ->
+ FileOutputStream(tmp).use { input.copyTo(it) }
+ }
+ return tmp
+ }
+
+ private fun progressInfo(text: String, index: Int, total: Int): ForegroundInfo {
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setContentTitle("Cloudreve camera backup")
+ .setContentText("$text (${index + 1}/$total)")
+ .setOngoing(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(NOTIF_ID, notification)
+ }
+ }
+
+ private fun createChannel() {
+ val manager =
+ applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Camera backup", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+
+ companion object {
+ private const val WORK_NAME = "camera_upload_periodic"
+ private const val WORK_ONCE = "camera_upload_once"
+ private const val CHANNEL_ID = "camera_upload"
+ private const val NOTIF_ID = 42
+ private const val MAX_PER_RUN = 100
+
+ private fun constraints(wifiOnly: Boolean): Constraints =
+ Constraints.Builder()
+ .setRequiredNetworkType(
+ if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
+ )
+ .setRequiresBatteryNotLow(true)
+ .build()
+
+ /** Schedules (or removes) the periodic sync and kicks an immediate run. */
+ fun apply(context: Context, wifiOnly: Boolean) {
+ val wm = WorkManager.getInstance(context)
+ val periodic = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
+ .setConstraints(constraints(wifiOnly))
+ .build()
+ wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic)
+ wm.enqueueUniqueWork(
+ WORK_ONCE,
+ ExistingWorkPolicy.REPLACE,
+ OneTimeWorkRequestBuilder()
+ .setConstraints(constraints(wifiOnly))
+ .build(),
+ )
+ }
+
+ fun cancel(context: Context) {
+ val wm = WorkManager.getInstance(context)
+ wm.cancelUniqueWork(WORK_NAME)
+ wm.cancelUniqueWork(WORK_ONCE)
+ }
+
+ /** Manual "sync now" — the periodic schedule is untouched. */
+ fun syncNow(context: Context, wifiOnly: Boolean) {
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ WORK_ONCE,
+ ExistingWorkPolicy.REPLACE,
+ OneTimeWorkRequestBuilder()
+ .setConstraints(constraints(wifiOnly))
+ .build(),
+ )
+ }
+ }
+}
diff --git a/desktop/crates/cloudreve-api/src/models/explorer.rs b/desktop/crates/cloudreve-api/src/models/explorer.rs
index 285922bd..f64141c5 100644
--- a/desktop/crates/cloudreve-api/src/models/explorer.rs
+++ b/desktop/crates/cloudreve-api/src/models/explorer.rs
@@ -254,6 +254,7 @@ pub struct FileThumbResponse {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option,
+ #[serde(default)]
pub obfuscated: bool,
}
@@ -575,3 +576,32 @@ pub enum FileEvent {
/// Batch of file events with data
Event(Vec),
}
+
+#[cfg(test)]
+mod tests {
+ use super::FileThumbResponse;
+
+ #[test]
+ fn thumb_response_without_obfuscated_field_deserializes() {
+ // The CE server's /file/thumb response carries only `url`/`expires`;
+ // a missing `obfuscated` flag must not fail deserialization — otherwise
+ // every thumbnail request errors out and Explorer shows no thumbnails
+ // for online-only placeholders.
+ let res: FileThumbResponse =
+ serde_json::from_str(r#"{"url":"https://example.com/t.jpg","expires":null}"#)
+ .expect("response without `obfuscated` must deserialize");
+ assert!(!res.obfuscated);
+ assert_eq!(res.url, "https://example.com/t.jpg");
+ assert!(res.expires.is_none());
+ }
+
+ #[test]
+ fn thumb_response_with_obfuscated_field_deserializes() {
+ let res: FileThumbResponse = serde_json::from_str(
+ r#"{"url":"abc","expires":"2026-01-01T00:00:00Z","obfuscated":true}"#,
+ )
+ .expect("response with `obfuscated` must deserialize");
+ assert!(res.obfuscated);
+ assert_eq!(res.expires.as_deref(), Some("2026-01-01T00:00:00Z"));
+ }
+}
diff --git a/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx
index 12df7ab1..16b9b6cb 100644
--- a/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx
+++ b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx
@@ -7,6 +7,7 @@ import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts"
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { loadFileThumb } from "../../../../redux/thunks/file.ts";
import { getFileLinkedUri } from "../../../../util";
+import { fetchMotionPhotoVideoUrl, isMotionPhotoCandidate } from "../../../../util/motionPhoto.ts";
import { FileManagerIndex } from "../../../FileManager/FileManager.tsx";
import { LRUCache } from "../../../../util/lru.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
@@ -154,12 +155,32 @@ export default function Photo({
});
}, []);
- const loadLivePhoto = async (file: FileResponse, imgUrl: string) => {
- if (!file.metadata?.[Metadata.live_photo]) {
+ const attachLivePhotoPlayer = (file: FileResponse, imgUrl: string, videoUrl: string) => {
+ const imgElement = document.getElementById(file.id);
+ if (!imgElement || !mountedRef.current) {
return;
}
+ const player = LivePhotosKit.Player(imgElement as HTMLElement);
+ playerRef.current = player;
+ player.photoSrc = imgUrl;
+ player.videoSrc = videoUrl;
+ player.proactivelyLoadsVideo = true;
+ };
+ const loadLivePhoto = async (file: FileResponse, imgUrl: string) => {
try {
+ if (!file.metadata?.[Metadata.live_photo]) {
+ // Android Motion Photo: a JPEG with an MP4 appended at EOF (#89).
+ if (!isMotionPhotoCandidate(file.name, file.size)) {
+ return;
+ }
+ const videoUrl = await fetchMotionPhotoVideoUrl(imgUrl);
+ if (videoUrl) {
+ attachLivePhotoPlayer(file, imgUrl, videoUrl);
+ }
+ return;
+ }
+
const fileExtended = await dispatch(
getFileInfo(
{
@@ -183,14 +204,7 @@ export default function Photo({
}),
);
- const imgElement = document.getElementById(file.id);
- if (imgElement) {
- const player = LivePhotosKit.Player(imgElement as HTMLElement);
- playerRef.current = player;
- player.photoSrc = imgUrl;
- player.videoSrc = livePhotoEntityUrl.urls[0].url;
- player.proactivelyLoadsVideo = true;
- }
+ attachLivePhotoPlayer(file, imgUrl, livePhotoEntityUrl.urls[0].url);
} catch (e) {
console.error("Failed to load live photo:", e);
}
diff --git a/frontend/src/util/motionPhoto.ts b/frontend/src/util/motionPhoto.ts
new file mode 100644
index 00000000..bf97bbdc
--- /dev/null
+++ b/frontend/src/util/motionPhoto.ts
@@ -0,0 +1,112 @@
+/**
+ * Android Motion Photo (a.k.a. MicroVideo) support.
+ *
+ * Google/Samsung motion photos are a single JPEG with an MP4 appended at the
+ * end. XMP metadata in the JPEG header either carries `GCamera:MicroVideoOffset`
+ * (bytes from EOF to the start of the MP4) or a `Container:Directory` whose
+ * `video/mp4` item declares `Item:Length` (video is the last item).
+ */
+
+// XMP packets live in the JPEG APP1 segment near the start of the file;
+// 128 KiB of head data covers every layout seen in the wild.
+const HEAD_SCAN_BYTES = 128 * 1024;
+
+// Motion photos carry a multi-MB video tail; smaller JPEGs cannot be one.
+const MIN_FILE_SIZE = 512 * 1024;
+
+const MOTION_PHOTO_EXTENSIONS = new Set(["jpg", "jpeg"]);
+
+export const isMotionPhotoCandidate = (name: string, size: number): boolean =>
+ MOTION_PHOTO_EXTENSIONS.has(name.toLowerCase().split(".").pop() ?? "") &&
+ size >= MIN_FILE_SIZE;
+
+const bytesToLatin1 = (buf: ArrayBuffer): string => {
+ const bytes = new Uint8Array(buf);
+ let out = "";
+ for (let i = 0; i < bytes.length; i += 8192) {
+ out += String.fromCharCode.apply(
+ null,
+ bytes.subarray(i, i + 8192) as unknown as number[],
+ );
+ }
+ return out;
+};
+
+// Extracts the embedded video's byte length (counted from EOF) from XMP text.
+const extractVideoLength = (text: string): number | null => {
+ const offset = text.match(/MicroVideoOffset="(\d+)"/);
+ if (offset) {
+ return parseInt(offset[1], 10);
+ }
+
+ if (!/MicroVideo|MotionPhoto/.test(text)) {
+ return null;
+ }
+
+ const mimeIdx = text.indexOf("video/mp4");
+ if (mimeIdx < 0) {
+ return null;
+ }
+ // The item element serializes Mime before Length; search forward only so a
+ // preceding image item's Length is never picked up.
+ const window = text.slice(mimeIdx, mimeIdx + 512);
+ const length = window.match(/Item:Length="(\d+)"/);
+ return length ? parseInt(length[1], 10) : null;
+};
+
+// MP4 files start with an `ftyp` box; guards against bogus XMP offsets.
+const looksLikeMp4 = (buf: ArrayBuffer): boolean => {
+ if (buf.byteLength < 12) {
+ return false;
+ }
+ const view = new DataView(buf, 4);
+ return view.getUint32(0) === 0x66747970; // "ftyp"
+};
+
+/**
+ * Returns a blob URL for the embedded MP4 of a motion photo, or null when the
+ * image has no embedded video or the bytes cannot be fetched (CORS, network).
+ * Uses HTTP Range requests so at most the head + video tail are transferred.
+ */
+export const fetchMotionPhotoVideoUrl = async (
+ imageUrl: string,
+): Promise => {
+ try {
+ const head = await fetch(imageUrl, {
+ headers: { Range: `bytes=0-${HEAD_SCAN_BYTES - 1}` },
+ });
+ if (!head.ok) {
+ return null;
+ }
+ const headBuf = await head.arrayBuffer();
+
+ const videoLength = extractVideoLength(bytesToLatin1(headBuf));
+ if (!videoLength || videoLength <= 0) {
+ return null;
+ }
+
+ let videoBuf: ArrayBuffer;
+ if (head.status === 206 && headBuf.byteLength <= HEAD_SCAN_BYTES) {
+ const tail = await fetch(imageUrl, {
+ headers: { Range: `bytes=-${videoLength}` },
+ });
+ if (!tail.ok) {
+ return null;
+ }
+ videoBuf = await tail.arrayBuffer();
+ } else {
+ // Range ignored: the head fetch already holds the whole file.
+ if (headBuf.byteLength <= videoLength) {
+ return null;
+ }
+ videoBuf = headBuf.slice(headBuf.byteLength - videoLength);
+ }
+
+ if (!looksLikeMp4(videoBuf)) {
+ return null;
+ }
+ return URL.createObjectURL(new Blob([videoBuf], { type: "video/mp4" }));
+ } catch {
+ return null;
+ }
+};