diff --git a/.woodpecker.env b/.woodpecker.env
index 93382bdda0d..fdaba07cee4 100644
--- a/.woodpecker.env
+++ b/.woodpecker.env
@@ -1,3 +1,3 @@
# The version of OpenCloud to use in pipelines
-OPENCLOUD_COMMITID=f293031e404d51a7da4a5c06d88b449dc226b9cb
+OPENCLOUD_COMMITID=4dec19089de0ab4b2d6e9d77bb3e11bb1dcc26f7
OPENCLOUD_BRANCH=main
diff --git a/.woodpecker.star b/.woodpecker.star
index b559db32ba9..ae24bf8f3fb 100644
--- a/.woodpecker.star
+++ b/.woodpecker.star
@@ -13,7 +13,7 @@ COLLABORA_CODE = "collabora/code:26.04.4.2.1"
KEYCLOAK = "quay.io/keycloak/keycloak:26.7.3"
MINIO_MC = "quay.io/opencloudeu/minio-mc-ci:1.0"
OC_CI_BAZEL_BUILDIFIER = "quay.io/opencloudeu/bazel-buildifier-ci:latest"
-OC_CI_GOLANG = "quay.io/opencloudeu/golang-ci:1.25"
+OC_CI_GOLANG = "quay.io/opencloudeu/golang-ci:1.26"
OC_CI_NODEJS = "quay.io/opencloudeu/nodejs-ci:24"
OC_CI_NODEJS_ALPINE = "quay.io/opencloudeu/nodejs-alpine-ci:24"
OC_CI_WAIT_FOR = "quay.io/opencloudeu/wait-for-ci:latest"
diff --git a/packages/web-app-preview/src/App.vue b/packages/web-app-preview/src/App.vue
index cd3cf17e26e..846e782283e 100644
--- a/packages/web-app-preview/src/App.vue
+++ b/packages/web-app-preview/src/App.vue
@@ -41,7 +41,7 @@
{
isVideo: isFileTypeVideo(file),
isImage: isFileTypeImage(file),
isAudio: isFileTypeAudio(file),
- isMotionPhoto: isFileTypeImage(file) && !isEmpty(file.motionPhoto),
+ isMotionOrLivePhoto: isMotionOrLivePhoto(file),
isLoading: true,
isError: false,
resource: file
diff --git a/packages/web-app-preview/src/components/MediaControls.vue b/packages/web-app-preview/src/components/MediaControls.vue
index 89842babb36..5f68ea337b6 100644
--- a/packages/web-app-preview/src/components/MediaControls.vue
+++ b/packages/web-app-preview/src/components/MediaControls.vue
@@ -156,7 +156,13 @@
diff --git a/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue b/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue
index b636e6cb471..7515be15cb0 100644
--- a/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue
+++ b/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue
@@ -20,16 +20,16 @@
v-if="isLoading && !isPlaying"
class="absolute inset-0 m-auto"
size="large"
- :aria-label="$gettext('Loading motion photo')"
+ :aria-label="loadingLabel"
/>
diff --git a/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue b/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue
index 86e4644b4d6..132fc943b2f 100644
--- a/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue
+++ b/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue
@@ -2,7 +2,7 @@
import { computed, useTemplateRef } from 'vue'
-import isEmpty from 'lodash-es/isEmpty'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import MotionPhotoPlayer from './MotionPhotoPlayer.vue'
+import { isMotionOrLivePhoto } from '../../composables'
const {
resource,
@@ -34,5 +34,5 @@ const {
}>()
const player = useTemplateRef>('player')
-const isMotionPhoto = computed(() => !isEmpty(resource?.motionPhoto))
+const showPlayer = computed(() => isMotionOrLivePhoto(resource))
diff --git a/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue b/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue
index 11e0f84ee68..7e553750647 100644
--- a/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue
+++ b/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue
@@ -20,6 +20,7 @@
:loading="isLoading"
:icon="isPlaying ? 'pause-circle' : 'play-circle'"
:label="badgeLabel"
+ :loading-label="badgeLoadingLabel"
@click.stop.prevent="toggle"
/>
@@ -30,7 +31,7 @@ import { computed, unref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import MotionPhotoBadge from './MotionPhotoBadge.vue'
-import { useGetMatchingSpace, useMotionPhotoPlayback } from '../../composables'
+import { isLivePhoto, useGetMatchingSpace, useMotionPhotoPlayback } from '../../composables'
// Owns the playback state, so mount it only for motion photos. Pointer-transparent
// apart from the badge; the parent drives hover-to-play via hoverPlay/stop.
@@ -58,10 +59,20 @@ const { isPlaying, isLoading, videoUrl, canPlay, hoverPlay, stop, toggle, seekTo
)
const badgeLabel = computed(() => {
+ if (isLivePhoto(resource)) {
+ if (!unref(canPlay)) {
+ return $gettext('Live Photo (video not available)')
+ }
+ return unref(isPlaying) ? $gettext('Pause Live Photo') : $gettext('Play Live Photo')
+ }
if (!unref(canPlay)) {
- return $gettext('Motion photo (clip not available)')
+ return $gettext('Motion Photo (video not available)')
}
- return unref(isPlaying) ? $gettext('Pause motion photo') : $gettext('Play motion photo')
+ return unref(isPlaying) ? $gettext('Pause Motion Photo') : $gettext('Play Motion Photo')
+})
+
+const badgeLoadingLabel = computed(() => {
+ return isLivePhoto(resource) ? $gettext('Loading Live Photo') : $gettext('Loading Motion Photo')
})
defineExpose({ isPlaying, hoverPlay, stop, toggle })
diff --git a/packages/web-pkg/src/components/FilesList/ResourceTile.vue b/packages/web-pkg/src/components/FilesList/ResourceTile.vue
index 89feb75b0e6..2e98e1ac964 100644
--- a/packages/web-pkg/src/components/FilesList/ResourceTile.vue
+++ b/packages/web-pkg/src/components/FilesList/ResourceTile.vue
@@ -85,7 +85,7 @@
resourcesStore.selectedIdsSet.has(resource.id))
// the preview is inset while selected or hovered (the whole media area, badge
// included, so that hovering the badge does not pop the still back out) and the
-// clip follows that inset; the badge keeps the checkbox's fixed edge distance
+// video follows that inset; the badge keeps the checkbox's fixed edge distance
const isMediaHovered = ref(false)
const motionInset = computed(() => isResourceSelected.value || isMediaHovered.value)
const motionVideoClass = computed(() =>
diff --git a/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts b/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts
index 184965fb0f7..3d58778d86e 100644
--- a/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts
+++ b/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts
@@ -1,28 +1,82 @@
+import { shallowReactive } from 'vue'
import { tryOnScopeDispose } from '@vueuse/core'
import isEmpty from 'lodash-es/isEmpty'
-import { Resource, SpaceResource } from '@opencloud-eu/web-client'
+import { isPublicSpaceResource, Resource, SpaceResource } from '@opencloud-eu/web-client'
import { useClientService } from '../clientService'
+import { useResourcesStore } from '../piniaStores'
+import { useGetMatchingSpace } from '../spaces'
+
+const PAIRED_VIDEO_SEARCH_LIMIT = 10
+const PAIRED_VIDEO_MISSING_TTL_MS = 30_000
+
+type PairedVideo = { resource: Resource; space: SpaceResource }
+
+function isPairedVideoOf(still: Resource, candidate: Resource): boolean {
+ return (
+ candidate.livePhoto?.contentId === still.livePhoto.contentId &&
+ !!candidate.mimeType?.startsWith('video/')
+ )
+}
+
+export function isMotionPhoto(resource: Resource): boolean {
+ return !isEmpty(resource?.motionPhoto)
+}
+
+export function isLivePhoto(resource: Resource): boolean {
+ return (
+ !isMotionPhoto(resource) &&
+ !!resource?.livePhoto?.contentId &&
+ !!resource.mimeType?.startsWith('image/')
+ )
+}
+
+export function isMotionOrLivePhoto(resource: Resource): boolean {
+ return isMotionPhoto(resource) || isLivePhoto(resource)
+}
/**
- * Handles the embedded video of a Google Motion Photo.
+ * Handles the video of a photo, which is either embedded or paired.
*
- * A Motion Photo is a still JPEG with a short MP4 clip appended to the end of
- * the file. The `motionPhoto` facet exposes `videoSize` (the byte length of
- * that appended clip), which lets us fetch just the video with a single HTTP
- * Range request instead of downloading the whole file: `Range: bytes=-`
- * where `offset = size - videoSize`.
+ * Embedded (Google Motion Photo): a still JPEG with a short MP4 video appended
+ * to the end of the file. The `motionPhoto` facet exposes `videoSize` (the byte
+ * length of that appended video), which lets us fetch just the video with a
+ * single HTTP Range request instead of downloading the whole file:
+ * `Range: bytes=-` where `offset = size - videoSize`.
+ *
+ * Paired (Apple Live Photo): the video is a separate QuickTime file that
+ * carries the same `livePhoto.contentId` as the still. It is looked up only
+ * when the video is requested and then downloaded as a whole.
*
* The composable owns the blob lifecycle: it memoizes the object URL per
* resource id (so hover + click don't double-fetch) and revokes every URL it
- * created when the owning scope is disposed.
+ * created when the owning scope is disposed. The paired video of a live photo
+ * is kept the same way.
*/
export function useMotionPhoto() {
const clientService = useClientService()
+ const resourcesStore = useResourcesStore()
+ const { getMatchingSpace } = useGetMatchingSpace()
const blobUrlCache = new Map()
+ const pairedVideoCache = new Map()
+ const missingPairedVideoTimers = shallowReactive(new Map>())
+
+ // "not found" is only held for a while: right after an upload the server may
+ // not have indexed the video yet, so a later attempt can still find it
+ function rememberMissingPairedVideo(resourceId: string): void {
+ const timer = setTimeout(
+ () => missingPairedVideoTimers.delete(resourceId),
+ PAIRED_VIDEO_MISSING_TTL_MS
+ )
+ missingPairedVideoTimers.set(resourceId, timer)
+ }
- const isMotionPhoto = (resource: Resource): boolean => !isEmpty(resource?.motionPhoto)
+ function forgetPairedVideo(resourceId: string): void {
+ clearTimeout(missingPairedVideoTimers.get(resourceId))
+ missingPairedVideoTimers.delete(resourceId)
+ pairedVideoCache.delete(resourceId)
+ }
- const getVideoOffset = (resource: Resource): number | null => {
+ function getVideoOffset(resource: Resource): number | null {
if (!isMotionPhoto(resource)) {
return null
}
@@ -38,10 +92,24 @@ export function useMotionPhoto() {
return offset
}
- const canPlay = (resource: Resource): boolean => getVideoOffset(resource) !== null
+ function canPlay(resource: Resource): boolean {
+ if (isLivePhoto(resource)) {
+ return !missingPairedVideoTimers.has(resource.id)
+ }
+ return getVideoOffset(resource) !== null
+ }
- // the facet marks an unspecified timestamp as -1
- const getStillTimestampSeconds = (resource: Resource): number | null => {
+ // the motion photo facet marks an unspecified timestamp as -1; a live photo
+ // carries the timestamp on its video half only, which is known once resolved
+ function getStillTimestampSeconds(resource: Resource, duration?: number): number | null {
+ if (isLivePhoto(resource)) {
+ const video = pairedVideoCache.get(resource.id)?.resource
+ const us = Number(video?.livePhoto?.stillImageTimeUs)
+ if (Number.isFinite(us) && us >= 0) {
+ return us / 1_000_000
+ }
+ return Number.isFinite(duration) ? duration / 2 : null
+ }
const us = Number(resource?.motionPhoto?.presentationTimestampUs)
if (!Number.isFinite(us) || us < 0) {
return null
@@ -49,15 +117,112 @@ export function useMotionPhoto() {
return us / 1_000_000
}
- async function loadVideoUrl(
+ function findListedPairedVideo(space: SpaceResource, resource: Resource): PairedVideo | null {
+ const listed = resourcesStore.resources.find((candidate) =>
+ isPairedVideoOf(resource, candidate)
+ )
+ if (!listed) {
+ return null
+ }
+ // search results and flat lists mix resources of several spaces
+ const isSameSpace = listed.storageId === resource.storageId
+ return { resource: listed, space: isSameSpace ? space : getMatchingSpace(listed) }
+ }
+
+ // FIXME: remove once the search REPORT returns the facets of its hits
+ // (opencloud-eu/opencloud#3652), the hit then carries the still image time
+ async function loadVideoFacets(
space: SpaceResource,
+ video: Resource,
+ signal?: AbortSignal
+ ): Promise {
+ if (video.livePhoto) {
+ return video
+ }
+ try {
+ return await clientService.webdav.getFileInfo(space, { fileId: video.fileId }, { signal })
+ } catch (error) {
+ if (signal?.aborted) {
+ throw error
+ }
+ return video
+ }
+ }
+
+ async function searchPairedVideo(
resource: Resource,
signal?: AbortSignal
- ): Promise {
- if (blobUrlCache.has(resource.id)) {
- return blobUrlCache.get(resource.id)
+ ): Promise {
+ try {
+ const { resources } = await clientService.webdav.search(
+ `livePhoto.contentId:"${resource.livePhoto.contentId}"`,
+ { searchLimit: PAIRED_VIDEO_SEARCH_LIMIT, signal }
+ )
+ const hit = resources.find(({ mimeType }) => mimeType?.startsWith('video/'))
+ if (!hit) {
+ return null
+ }
+ const space = getMatchingSpace(hit)
+ return { resource: await loadVideoFacets(space, hit, signal), space }
+ } catch (error) {
+ if (signal?.aborted) {
+ throw error
+ }
+ return null
+ }
+ }
+
+ async function resolvePairedVideo(
+ space: SpaceResource,
+ resource: Resource,
+ signal?: AbortSignal
+ ): Promise {
+ if (pairedVideoCache.has(resource.id)) {
+ return pairedVideoCache.get(resource.id)
+ }
+
+ if (!missingPairedVideoTimers.has(resource.id)) {
+ const canSearch = !isPublicSpaceResource(space)
+ const video =
+ findListedPairedVideo(space, resource) ??
+ (canSearch ? await searchPairedVideo(resource, signal) : null)
+ if (video) {
+ pairedVideoCache.set(resource.id, video)
+ return video
+ }
+ rememberMissingPairedVideo(resource.id)
+ }
+ throw new Error('paired video of the live photo not found')
+ }
+
+ async function loadPairedVideo(
+ space: SpaceResource,
+ resource: Resource,
+ signal?: AbortSignal
+ ): Promise {
+ const video = await resolvePairedVideo(space, resource, signal)
+ // the id survives a move or rename of the video, public links only know paths
+ const { fileId, path } = video.resource
+ const file = isPublicSpaceResource(video.space) ? { path } : { fileId }
+ try {
+ const { body } = await clientService.webdav.getFileContents(video.space, file, {
+ responseType: 'blob',
+ signal
+ })
+ return body
+ } catch (error) {
+ if (!signal?.aborted) {
+ pairedVideoCache.delete(resource.id)
+ }
+ throw error
}
+ }
+ async function loadEmbeddedVideo(
+ space: SpaceResource,
+ resource: Resource,
+ signal?: AbortSignal
+ ): Promise {
const offset = getVideoOffset(resource)
if (offset === null) {
throw new Error('resource is not a playable motion photo')
@@ -70,9 +235,24 @@ export function useMotionPhoto() {
)
// a 200 means the server ignored the Range header and sent the whole file
- const raw: Blob = response?.status === 200 ? body.slice(offset) : body
+ return response?.status === 200 ? body.slice(offset) : body
+ }
- // the response is typed image/jpeg (the file's type), which