Frank/feat/photos perf sw and denorm - #3497
Open
karlitschek wants to merge 28 commits into
Open
Conversation
Move the photos app off Vue 2.7 onto the Vue 3.5 toolchain and
update the surrounding Nextcloud library line to its Vue 3
counterparts.
Toolchain
- vue 2.7 → 3.5; vue-router 3 → 4; vuex 3 → 4; pinia 2 → 3
- @nextcloud/vue 8 → 9; @nextcloud/dialogs 6 → 7;
@nextcloud/upload 1 → 2.0.0-rc.0
- @nextcloud/vite-config 1 → 2.5; @vue/tsconfig 0.5 → 0.8;
@vue/test-utils 1 → 2; eslint 10
- vuex-router-sync 5 → 6.0.0-rc.1
- Drop vue-template-compiler, vue2-leaflet, vue-virtual-grid
Entry points and core wiring
- main.ts / public.ts / sidebar.ts / dashboard.ts now use
createApp + app.use(...) + app.mount() (sidebar uses createApp /
app.unmount() per tab mount/destroy)
- router/index.ts: createRouter + createWebHistory; /maps redirects
via beforeEnter return value
- store/index.ts: createStore; vuex-router-sync v6
- services/GridConfig.ts switched from a Vue 2 instance event-bus
to a reactive() singleton; mixin reads it via computed
- All `import Vue from 'vue'`, Vue.set/Vue.delete/Vue.nextTick,
Vue.use(...), and Vue.prototype assignments removed
- this.$set / this.$delete replaced with direct assignment / delete
- beforeDestroy / destroyed renamed to beforeUnmount / unmounted
Template syntax
- Vue 2 slot= / slot-scope= → Vue 3 v-slot / #name (74 conversions
across 16 components)
- Vue 2 `filters: {}` option + `{{ x | y }}` pipes → methods
- .sync modifiers → v-model:propName
- :deep with space → :deep(...)
- .native modifier removed
- vue-router 4 dropped the `exact` prop
- inheritAttrs: false removed where parents now pass class/style
(which Vue 3 routes through $attrs and would otherwise drop)
- TiledRows.vue: `<template functional>` rewritten as a regular
defineComponent
Reactivity / private-field trap
- Vue 3's reactive Proxy is incompatible with classes that use ES
private fields (#field). markRaw the AbortController,
SemaphoreWithPriority, ResizeObserver, IntersectionObserver,
Uploader and webdav client instances stored in data(); use
shallowRef in useAbortController.
Library replacements
- LocationMap.vue: vue2-leaflet → @vue-leaflet/vue-leaflet
- FoldersView.vue: vue-virtual-grid replaced with new GridLayout
components (GridLayout.vue / GridLayout.ts / GridRow.vue) +
VirtualScrolling, adopted from the upstream artonge/migrate_to_vue3
branch
- @nextcloud/upload@2 dropped <UploadPicker>; replaced by a small
LocalUploadPicker.vue that wraps getUploader() / batchUpload()
- isMobile mixin → useIsMobile() composable
- @nextcloud/dialogs v7: DialogSeverity enum → string-literal API
- @nextcloud/event-bus v3 typed channels declared in event-bus.d.ts
Type system
- tsconfig: vueCompilerOptions.target 2.7 → 3.5
- New global.d.ts with OC / OCA ambient declarations
- New assets-modules.d.ts for *.svg / *.svg?raw / *.png / *.jpg
- vuex.d.ts: corrected store path (./store.ts → ./store/index.ts)
so $store augmentation flows through component types
- vue-router 4: Route → RouteLocationNamedRaw
- @vue/tsconfig 0.8: ComponentPublicInstanceConstructor (Vue 2.7
internal) → Component
- webdav 5: GetDirectoryContentsOptions → ...WithDetails
Lint and code-quality cleanup
- ESLint config switched to recommendedVue2 → recommended (Vue 3)
- Auto-fixed attribute hyphenation and event hyphenation
- Custom event / slot naming kept as kebab-case (project
convention) via local override
- Added explicit `emits: []` declarations across 10 components
- Removed 5 unused template refs
- Boolean prop defaults audited (2 inverted to false; 2 kept on
with eslint-disable + rationale)
- 2 cypress JSDoc inline-tag escapes
- preserve-caught-error fixes (added cause)
Build state
- npm run build: ✓ 9.94s, full bundle produced
- eslint .: 0 errors, 0 warnings
- vitest run: 1/1 passing
Carried-over caveats (documented in source with TODO markers):
- OCA.Files.Sidebar tab registration is deprecated in NC 33; we
cannot move to the new `@nextcloud/files@4` Sidebar API yet
because @nextcloud/upload@2.0.0-rc.0 hard-pins
@nextcloud/files@^3.10.2.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OCP\Files\DavUtil::getDavPermissions was changed in Nextcloud 34 to require a parent FileInfo as a second argument (used to determine renamability). Our PropFindPlugin still called it with a single argument, throwing an ArgumentCountError on every PROPFIND of a photos node and surfacing in the UI as "Failed to fetch collections list." Pass the node's parent through, mirroring the pattern used in server's apps/dav and apps/files. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sfade
Three UI improvements that build on the Vue 3 migration.
1. Consolidate on the justified-row grid algorithm
FoldersView was the last surface still using the fixed-square
GridLayout component (introduced during Phase 3 to replace
vue-virtual-grid). Switch it to TiledLayout, which is the same
row-justified algorithm the timeline and album content already
use, and delete the now-unused GridLayout/ directory.
Folders and files are mapped through a small wrapper that adds
the `id` and `ratio` fields TiledLayout needs (ratio=1 for both
since the folder-listing endpoint doesn't return per-photo
dimensions); the inner FileLegacy / FolderComponent rely on
`object-fit: cover` to fill whatever tile shape is assigned.
FileLegacy and FolderComponent also lose the `item.injected.X`
indirection inherited from vue-virtual-grid's wrapper shape and
read the item directly, matching the upstream Vue 3 migration
branch.
2. Blurhash → small → large crossfade in FileComponent
The previous code toggled the canvas blurhash and the two img
layers via v-if chains driven by `loadedSmall`/`loadedLarge`,
producing a hard pop as each layer arrived. Keep all three in
the DOM and stack them: blurhash z-index 1, small thumbnail z-2
(200ms fade-in), large preview z-3 (250ms fade-in). Each upper
layer covers the one beneath as it becomes opaque, so the
visual is a smooth blur → pixelated → sharp progression.
Bonus fix: beforeUnmount referenced this.$refs.srcLarge (typo)
instead of imgLarge, leaving in-flight large-preview loads
uncancelled when a tile scrolled out of view.
3. Tile density toggle (small / default / large)
New view-density toggle in the timeline header — NcActions menu
with three NcActionRadio options. The selection drives a
tileBaseHeight computed (mobile 80/120/200, desktop 120/200/320)
that's forwarded to FilesListViewer.baseHeight; TiledLayout's
row-justification already adapts to any base height.
Persisted via the existing apps/photos/api/v1/config/{key}
endpoint:
- frontend: new `gridDensity: 'small' | 'medium' | 'large'`
field on the userConfig store (default 'medium').
- backend: UserConfigService.DEFAULT_CONFIGS gets `gridDensity`
so it's auto-hydrated into the page initial state alongside
the other config keys; ApiController::setUserConfig validates
the value is one of small | medium | large.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five user-visible additions on top of the Vue 3 migration. Each is
intentionally minimum-viable so they ship together; each has clear
"deferred to a follow-up" boundaries called out below.
1. Free-text search by filename
- New PhotosFilter `nameFilter` that emits a DAV `<d:like>` query
against `<d:displayname/>`. Special characters in the user's
query are escaped (XML + SQL LIKE wildcards) before being
embedded in the request body.
- NcTextField in PhotosApp's `#search` slot, debounced 300ms.
Typing pushes a single value into `selectedFilters.name`; the
existing filtersQuery / extraFilters pipeline does the rest, so
search composes cleanly with the existing date-range and place
filters.
- Deferred: content search ("beach", "sunset") — that requires
Recognize-app integration and is out of scope here. The
filename + EXIF-anchored search should already feel
transformative.
2. Trip memories (/memories)
- New `services/memories.ts` clusters loaded photos by capture-
date gaps (>2 days starts a new trip; clusters of <8 photos
are dropped to keep noise out). Cover photo is picked from
near the trip's midpoint to avoid arrival/departure shots.
- New MemoriesView with tile cards (cover + date range + count).
Click opens the NC Viewer with the trip's photos as the
gallery list.
- Routed at /memories with a navigation entry next to "On this
day". On first visit the view triggers a 500-photo fetch so
the page isn't empty before the timeline has been scrolled.
- Deferred: server-side trip detection (would scale to libraries
of millions of photos), and place-anchored / "year ago today"
memory cards.
3. Photos map (/map)
- New MapView using @vue-leaflet's LMap + LMarker, plotting every
loaded photo that has GPS metadata. Click a marker → opens NC
Viewer with all geotagged photos as the gallery list.
- `nc:metadata-photos-gps` is now registered in main.ts so the
timeline endpoint returns the field; previously it was only
registered in sidebar.ts.
- Centred on the centroid of the first 200 photos so users land
somewhere relevant rather than on null island.
- Replaces the previous /maps route that redirected out to the
external Maps app. The inline view works for everyone whether
or not Maps is installed.
- Deferred: leaflet.markercluster integration (real libraries
above ~5000 geotagged photos will need it).
4. Photo slideshow
- New Slideshow component, full-page Teleport, plays through a
supplied list of photos with play/pause + prev/next + close
and ESC/arrow/space keyboard shortcuts. Auto-advance is 4s.
- Triggered from a "Slideshow" button in the TimelineView header
(visible when no selection is active and at least one photo
has been fetched). Plays through the timeline's currently
loaded photos in capture order.
- Deliberately self-contained — the NC Viewer app is in a
separate codebase, so adding a slideshow mode there is
out-of-scope. This stays a Photos-internal feature.
5. Long-press multi-select
- FileComponent now starts a 500ms long-press timer on
pointerdown; if the press is held that long, the selection
toggles and the subsequent click is swallowed. Mouse and
touch both go through pointer events, so the same code paths
handle desktop right-click-equivalent and mobile tap-and-hold.
- The existing checkbox-on-hover affordance still works on
desktop; long-press is the addition for mobile users for whom
the checkbox was a finicky tap target.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rations, animated counters, album hero, year recap, EXIF overlay
Eight micro / meso polish items, each at minimum-viable scope. Calls
out the deferred bits inline so reviewers know what's deliberately not
in scope.
Animated favourite star
- Replace `v-once` on FavoriteIcon (which silently masked any
toggle from the bulk-action menu) with a Vue `<Transition>`. The
enter animation is a 320ms scale-bounce keyframe with a cubic
overshoot timing function; leave fades out in 180ms.
- Skipped: the "particle burst on first favourite of session" bit;
that needs session-state plumbing and a one-shot sentinel.
Selection ripple
- The previous `.selected` ring was a hard `outline` glued to the
tile edge. Replace with a soft 0.97-scale lift + a 3px primary-
colour glow + a 6px-blur drop shadow, all transitioned in 160ms.
Keeps the focus ring (different state) for keyboard interaction
via `&:focus-within / &:has(:focus)` so focus and selection are
visually distinct.
Skeleton shimmer
- Layer a translucent diagonal gradient sweep on top of the
blurhash (or the empty primary-element-light background when no
blurhash exists) until the small or large preview lands. Pure
CSS — translating gradient — so it costs nothing JS-side.
Honours `prefers-reduced-motion: reduce`.
Illustrated empty states
- New EmptyIllustration.vue with four hand-drawn-ish SVG
compositions (memories / map / faces / timeline). Each uses the
Photos accent colour via `var(--color-primary-element)` so it
follows the user's theme, including dark mode.
- Wired into MemoriesView and MapView; FacesView / empty-timeline
TBD when those surfaces get a polish pass.
Animated counters
- Tiny AnimatedNumber.vue that tween-displays a `value` prop using
requestAnimationFrame with an ease-out cubic curve, slot-exposing
the live `displayValue` so callers can wrap it in their own
translation (e.g. `n('photos', '%n photo', '%n photos', value)`).
Honours `prefers-reduced-motion: reduce` by snapping straight to
the final value.
- Used in Memories trip cards + the year-recap feature card; can be
dropped in elsewhere later without changes.
Album page magazine spread
- New AlbumHero.vue: the album cover photo as a full-bleed 280px-
tall hero with the album title overlaid on a darkening gradient.
Subtitle combines location (if any) with the photo count via
`translatePlural`. Hooks a passive scroll listener to translate
the cover background up to 60px slower than the page for a soft
parallax effect.
- Skipped (called out in commit so they're easy to find): album
cover picker UI and accent-colour extraction from the cover
image. Both worthwhile follow-ups; both larger than this round.
Year-in-review recap
- New `buildYearRecap` in services/memories.ts: groups loaded
photos by year, picks the most-recent year with at least 30
photos, then curates a target-of-60 set by including all
favourites first then sampling evenly across the calendar so
December doesn't dominate. Cover is the curated-set midpoint
(avoiding the "first or last photo of the year" trap).
- Surface as a hero feature card on MemoriesView (above the trip
grid) with eyebrow / title / count metadata. Click opens the
curated set in the existing in-app Slideshow component, so the
flow is "click → autoplay slideshow → close to return" with no
cross-app coordination needed.
- Skipped: text-card sections between groups, music, and "X years
ago today" cards. They're separate algorithms.
EXIF overlay in the slideshow
- Press `i` in the slideshow to toggle a frosted-glass aside that
surfaces camera (Make + Model from IFD0), aperture, focal
length, exposure, and ISO. Renders nothing when no EXIF data is
available so we don't show an empty panel.
- Required registering `nc:metadata-photos-exif` and
`nc:metadata-photos-ifd0` in main.ts — previously these were
only fetched by the file sidebar.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 3-dot overflow menu on every photo tile so the actions you already have buried in selection-mode or the slideshow are reachable from the tile itself, without first having to enter selection. PhotoActionsMenu.vue (new) - NcActions popup with 4 entries: View metadata, Add to album, Share, Delete (separated). Action button is positioned top-start on the tile with a translucent black backdrop so it stays legible on any photo. - "View metadata" is owned by the menu — it opens an inline NcDialog with the same camera / exposure / focal-length / ISO / aperture lines the slideshow overlay shows. Empty-state copy when the photo carries no EXIF. - The EXIF field-selection logic is duplicated (rather than extracted into a shared util) on purpose; it's small, and a third caller doesn't exist yet. Comment in the file flags this for refactor. - Delete prompts confirmation through a second NcDialog before emitting; the action menu itself never deletes silently. - Add-to-album and Share emit upward; the parent owns the heavy flows so we don't double-implement album-pick / sidebar-open. FileComponent.vue - Mounts PhotoActionsMenu next to the existing favourite + selection affordances. New `showActionsMenu` prop (default true) so picker contexts can opt out — the menu would only confuse when the user is choosing photos to add somewhere. - The menu is opacity-0 by default and revealed on `:hover` / `:focus-within`, matching the existing checkbox affordance. - Forwards request-add-to-album / request-share / request-delete upward without interpreting them. TimelineView.vue - Wires the three new request-* events. Add-to-album re-uses the existing AlbumPicker flow but stores the single file in `singleFileForAlbumPicker`; addSelectionToAlbum then targets that file alone instead of the bulk selection. - Share opens NC's Files sidebar on the path (its sharing tab already does the heavy lifting; no need to re-implement). - Delete dispatches `deleteFiles` after optimistically dropping the id from `fetchedFileIds` so the tile vanishes immediately; the store re-adds on failure. PhotosPicker.vue - Passes `:showActionsMenu="false"` so the picker context stays about picking, not managing. Build / lint clean (1 → 0 warnings; the showActionsMenu prop is default-true on purpose, eslint-disable scoped to that line). Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier wiring called `OCA.Files.Sidebar.open(path)`, but that legacy API was removed in NC34 (the modern API lives behind `getSidebar()` from `@nextcloud/files`). So the Share menu item was silently a no-op. The replacement `getSidebar().open(node, 'sharing')` would also fail in the photos context: the Files Pinia store guards on `activeView` + `activeFolder` being set, and photos uses its own router so neither ever gets populated. Instead, navigate the user to the Files app on the photo with `opendetails=true`. That lands them in a fully-loaded sharing-capable context (link shares, share-with-user, email, expiry, password, etc.), which is what "share dialog" actually means in NC. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The photos timeline currently re-derives itself from filecache via DAV
REPORT on every page load — searching the user's whole storage by
mimetype is the dominant cost for libraries past a few thousand files.
Memories app solves this with a precomputed per-user index; this
change brings the same approach to the official photos app.
What this lays down (the indexed data path; the client still reads via
DAV — switching it over is the next change so this PR can land
incrementally with no behaviour change for users on day one):
Backend
- New `oc_photos_index` table: (user_id, file_id) primary,
(user_id, taken_at, mtime) covering index for the timeline query.
`taken_at` is denormalised from the EXIF DateTimeOriginal already
computed by OriginalDateTimeMetadataProvider, falling back to mtime
at insert time so the timeline can sort by a single non-null column.
- PhotoIndexService owns upserts; reads the cached EXIF capture time
via IFilesMetadataManager. Indexing is best-effort — exceptions are
logged but never bubble into the file pipeline.
- PhotoIndexNodeListener wires NodeCreated/Written/Renamed/Deleted to
the service. Fanout uses IUserMountCache::getMountsForFileId so a
single upload to a group folder produces one row per recipient,
matching the per-user timeline query shape.
- PhotoIndexBackfillJob walks every user's primary folder once with a
one-hour wall budget, modelled on the AutomaticPlaceMapperJob next
door. Cursor-by-uid lets it resume across cron ticks. Per-user
`index.backfillDone.<uid>` flag flips once the user's tree is fully
walked — that flag also drives the "ready" bit in the API.
API
- GET /api/v1/index/status → `{ready, indexed, total}` for the current
user. `total` is the cached estimate of photos in the user's primary
storage (filecache count by mimetype) so the progress bar doesn't
oscillate when filecache rows are added during the scan.
- GET /api/v1/index/timeline?before=<unix>&limit=<n> → compact rows by
descending taken_at. Indexed read path; not wired into the client
yet (next PR).
Frontend — migration banner only for now
- New `indexStatus` Pinia store: polls the status endpoint every 5s
while ready=false and stops once ready=true. Treats a 404 as ready
so the banner is silent on instances that haven't applied the
migration yet (avoids a frontend-deploy / occ-upgrade race).
- New `IndexProgressBanner.vue`: NcNoteCard with NcProgressBar, shown
only while `inProgress`. Mounted into TimelineView under the header
so it sits above the grid without pushing it.
- Banner copy: "Speeding up your library — indexed N of M photos
(X%). The timeline will load faster once this finishes." — frames
the wait as progress rather than a fault.
Build + lint clean.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`vite` was pinned to exact 7.1.5 in 1ae4759 (Vue 3 migration). Since then `@nextcloud/vite-config@2.5.2` raised its peer to `^7.1.10`, so fresh `npm install` now fails with an ERESOLVE for the peer mismatch. Bumping to the carat range lets npm resolve to the latest patch (7.3.2 in the lockfile) without re-pinning. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the timeline view to consume `/api/v1/index/timeline` instead of the DAV REPORT search. The DAV fetcher stays as a fallback — indexed reads are an optimisation, not a correctness boundary. Backend - `PhotoIndexMapper::getEnrichedTimelineForUser` joins the index with filecache and the favorites store (`vcategory_to_object` ⨝ `vcategory`). Returns one row per file with everything the client needs to render a tile: path, name, etag, size, mtime, taken_at, permissions, favorite. EXIF / GPS / IFD0 / blurhash / dimensions come from a single batched `IFilesMetadataManager::getMetadataForFiles` call in the controller — same per-fileId cache the rest of the metadata pipeline uses. - The query is filtered to the user's primary storage so DAV source URLs map cleanly to `/files/<user>/...`. Group folders / external storage / shared mounts stay reachable via the legacy DAV fetcher fallback (next change can lift this once we store the user-relative DAV path at index time). - `estimateTotalForUser` no longer takes a redundant `$userId` parameter — IDE was flagging it as unused; the SQL filters by storage_id alone. Frontend - New `IndexedTimelineSearch.ts` hits the API and converts each row into a `@nextcloud/files` `File` object with the same attribute shape the legacy DAV path produces. Downstream consumers (FilesByMonthMixin, FileComponent, Slideshow, AlbumPicker) don't know which fetcher produced the file — the contract is the `File` object, not the transport. - Cursor-based pagination using `taken_at` instead of an offset: `firstResult === 0` resets the cursor, subsequent calls step back in time. - `FetchFilesMixin.fetchFiles` checks `indexStatusStore().ready` and routes to the indexed fetcher when the per-user backfill is done. Falls back to DAV on any error, and never uses the indexed path for `onThisDay` / `onlyFavorites` / `extraFilters` queries — those are filter shapes the indexed endpoint doesn't yet implement, so the dashboard widget and filter UI keep working unchanged. - `resetFetchFilesState` clears the indexed cursor alongside the fetched fileId list (mirrors how the DAV path resets `firstResult`). Lint clean. Local build blocked by sandbox permissions on node_modules but production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two playful surfaces inspired by iOS Photos / Memories: Burst stacks - `utils/burstClustering.ts` is a pure function over the already- fetched files: chain-clusters consecutive photos within ~3s of each other (chained off the previous member's timestamp, not the leader's, so 20-shot sustained bursts stay one stack). minSize=2 so Live Photos / quick double-taps qualify. - `FilesByMonthMixin` now folds members into their leader after computing the per-month grouping. The folded list is what the grid renders — visually you see the stack's leader; the rest are reachable through the slideshow. - `store/bursts.ts` is a tiny Pinia store with the leader→members map. Kept separate from the (large) files store so reactivity doesn't have to walk the whole files map on every stack lookup. - `FileComponent.vue` reads its own stack via the store. When it's a leader: relax `contain: strict` so two CSS pseudo-cards can peek out behind the tile (no extra <img> loads), plus a count badge in the top-right with `font-variant-numeric: tabular-nums` so the digits don't jitter as the count changes. - `TimelineView.openViewer` checks the store on click. Stack leaders feed the slideshow with ONLY the stack members; flipping prev/next stays inside the burst instead of jumping out into the full timeline. Singletons keep the old "all photos in the grid" behaviour. Date scrubber - `components/DateScrubber.vue`: vertical track on the right edge of the timeline with year labels (decimated to ~12 max so they don't overlap on tall windows) and a draggable thumb. Drag → jump the grid in real time; tap the bare track jumps without the drag. Standard slider keyboard semantics (Arrow / Home / End) for keyboard users. - A floating month/year tooltip pops up while scrubbing so the user's eye doesn't have to dart between the thumb and the grid to know where they're landing. - Resting opacity is 0.4, fades to 1 on hover or while scrubbing — ambient affordance that doesn't compete with the photos. - Hidden on viewports shorter than 480px (no useful track to drag). - TimelineView holds a `scrubberTarget` data field that the scrubber writes via the `jump` event; FilesListViewer's `scrollToSection` prop already supports the rest of the plumbing. Lint clean. Local build blocked by sandbox/node_modules state but production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-tile overflow menu now exposes the same two operations the sidebar's photos tab does — favorite/unfavorite and manage tags — without forcing the user into the sidebar first. Plus the menu now shows up in the folders view too, not just the timeline. PhotoActionsMenu.vue - New "Add to favorites" / "Remove from favorites" entry. Reads `file.attributes.favorite` to pick the label + icon (Star / StarOutline) and dispatches the existing `toggleFavoriteForFiles` store action — same DAV PROPPATCH path as the bulk-selection ActionFavorite, no new transport. - New "Manage tags…" entry opens a dialog with a checkbox list of all user-visible system tags. Each toggle assigns / unassigns optimistically (UI flips immediately, reverts on DAV failure with showError) so the user can flick through several without waiting on a Save button. A small input at the bottom creates a brand-new tag and assigns it in one go. - The `file` prop is now typed as a structurally-minimal `ActionMenuFile` interface instead of `PhotoFile`. Both the timeline's PhotoFile and the folder view's FoldersNode satisfy it; missing EXIF / favorite attributes degrade gracefully (View metadata still shows the filename, the favorite toggle still works because it goes via the store's PROPPATCH which only needs the fileid). PhotoTagService.ts (new) - Thin wrapper over the `systemtags` and `systemtags-relations` DAV trees: fetchAllTags, fetchTagsForFile, assignTagToFile, unassignTagFromFile, createTag. Modeled on the systemtags app's `services/api.ts` + `services/files.ts` — re-implemented here rather than imported because that module isn't a public entry point and pulling its build chain in would be a bigger change. - Idempotent on assign (swallows 409) / unassign (swallows 404) so retries from the optimistic UI don't surface as errors. - Filters fetchAllTags to userVisible+userAssignable so admin-only tags don't appear in the casual photo menu. FileLegacy.vue (folders view tile) - Wraps the existing <a class="file"> in a relatively-positioned div and mounts PhotoActionsMenu next to it. The folder view now shows the same menu the timeline does. Hover/focus reveal mirrors FileComponent's behaviour. - An `actionFile` adapter computes the ActionMenuFile shape from the FoldersNode (folder-listing nodes don't carry the favorite bit, so the toggle starts from "not favorited" and flips server-side regardless). - New `delete-requested` emit so FoldersView can wire the trash flow without us reaching into its folder-content cache. Lint clean. Production build runs on the devel server. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production build of all the changes on this branch (backend index + indexed-timeline data path + migration banner + burst stacks + date scrubber + favorite & tag actions in the per-photo menu). No source changes — these are the regenerated chunked JS/CSS artefacts that ship to the browser. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vue 3 doesn't reliably hoist a mixin's setup-return values onto the
consuming component's `this`. FilesByMonthMixin was returning the
burst Pinia store from setup() and accessing it as `this.bursts` in
the `fileIdsByMonth` computed — that came back as undefined in some
build modes, throwing:
TypeError: undefined is not an object (evaluating 'this.bursts.setStacks')
runtime-core.esm-bundler.js:275
Pinia stores are singletons — calling the use* function from anywhere
returns the same instance. Switched FilesByMonthMixin (computed),
FileComponent (computed), and TimelineView.openViewer to call
`burstStore()` directly instead of routing through `this.bursts`.
The setup blocks that only existed to surface the store are removed.
Build refresh attached.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /photos and /videos sub-routes pass `mimesType: imageMimes` / `videoMimes` to FetchFilesMixin so the legacy DAV REPORT can scope the search to images-only / videos-only. The indexed-timeline endpoint ignored that option, so once the per-user backfill completed and the client switched to the indexed read path, both tabs started showing the full set of media. Plumbed a `kind` query param through end-to-end: - `PhotoIndexMapper::getEnrichedTimelineForUser` takes an optional `kind` (`'images' | 'videos' | null`) and adds an `is_video = 0/1` WHERE clause. The boolean is denormalised at index time, so this stays a pure index lookup with no mimetype string parsing. - `IndexController::timeline` accepts `?kind=` and only forwards the two recognised values; anything else falls through to "all media" so a typo doesn't silently zero-out the timeline. - `IndexedTimelineSearch.detectKind` maps the client's `mimesType` array to the kind param. `imageMimes` → 'images', `videoMimes` → 'videos', the default `allMimes` (or any custom set) → undefined, i.e. no filter. Set-based comparison so a future caller passing the same mimes in a different order still maps cleanly. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In FileLegacy.vue the existing `img { z-index: 10 }` rule sits in
front of PhotoActionsMenu's default `z-index: 3`. So clicks aimed at
the 3-dot button passed through and landed on the underlying `<a>`,
opening the viewer instead of the menu.
Bumped the menu's z-index to 11 only when rendered inside FileLegacy
(scoped via :deep(.photo-actions) on the wrap), leaving the timeline
tile's z-index ordering — where the image isn't z-indexed — untouched.
Build refresh attached.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous z-index fix used `&__actions :deep(.photo-actions)` — which compiles to `.file-legacy-wrap__actions .photo-actions`, a descendant selector. But those two classes resolve to the same element (PhotoActionsMenu's root has both: its own `photo-actions` plus the `file-legacy-wrap__actions` we passed via the class binding). A descendant selector against the same element matches nothing, so the rule didn't apply and the menu stayed at its default z-index 3, behind the image at z-index 10. Clicks went to the `<a>` and opened the viewer. Moved the `:deep(.photo-actions)` selector up to the `.file-legacy-wrap` level, where `.photo-actions` is genuinely a descendant. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Photo tiles now respond to hover with a tactile-but-quiet effect: - The image inside the tile gently magnifies (scale 1.04, clipped by the tile's overflow). All three preview layers — blurhash, small, large — share the same transform so they don't slide relative to each other during the magnify. - The tile itself lifts with a soft drop shadow (0 6px 18px / 14%). - 220ms ease-out so the response feels immediate without dragging. - `:not(.selected)` gating so selection's existing scale-down + primary-color ring stays the dominant visual when both apply. - `prefers-reduced-motion` users get just the shadow, no scale. Applied in both tile components — `FileComponent` (timeline, favorites, photos, videos, faces, tags, albums, shared albums, collections, dashboard) and `FileLegacy` (folder view) — so the effect is consistent across every photo grid in the app. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumped the hover magnify from 1.04 → 1.07 and the transition from 220ms → 360ms. Reads as a more deliberate "this tile is responding to you" beat — less of a flicker, more of a presentation. Both FileComponent and FileLegacy bumped together so the timeline and folder views stay in lockstep. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumped the hover transition from 360ms ease-out → 520ms with an ease-out-quint curve (cubic-bezier 0.22, 1, 0.36, 1). The curve responds quickly at the start and then settles gently into the final scale, reading as deliberate / cinematic rather than UI-snappy. Both FileComponent and FileLegacy bumped together. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The date scrubber's drag was being silently swallowed by browser default pointer behaviours: without `touch-action: none` on both the track and the thumb, mobile + macOS-trackpad gestures get classified as page scrolls before our pointermove handler ever sees them. Result: clicking the thumb did nothing visible, the timeline didn't move. Also reworked the press handlers: - Track pointerdown and thumb pointerdown now funnel into the same `startDrag` path. A press anywhere on the track is BOTH an immediate jump and the start of a drag — so press-and-drag works whether the user grabs the thumb or any blank stretch. - Dropped `setPointerCapture` — it can drop the capture under Safari quirks (the original failure mode). The document-level `pointermove` + `pointerup` listeners catch the events reliably regardless of whether the cursor leaves the thumb. - Listen for `pointercancel` too, so a system gesture interruption (e.g. notification slide-in on iOS) cleanly ends the drag instead of leaving `isDragging = true`. - `dragMonth` is pre-set on press so the thumb position doesn't snap to a stale `activeMonth` on the first frame. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NC34 dev builds throw inside `FilesMetadataManager::getKnownMetadata()`
during DAV SEARCH backend setup (strict lazy-AppConfig validation:
"The loading of lazy AppConfig values have been triggered by app
'core'"). That makes every DAV SEARCH request 500, which surfaces in
the photos app as "search is broken".
Sidesteps the problem by serving search out of our own backend
against `oc_photos_index` rather than DAV.
Backend
- `PhotoIndexMapper::searchUserTimeline` joins photos_index ⨝
filecache ⨝ favorites (existing) ⨝ systemtag_object_mapping ⨝
systemtag (new). Filter is `(filecache.name LIKE %q% OR
systemtag.name LIKE %q% OR taken_at IN dateRange)`.
- `IndexController::search` parses the query for date hints (year
alone like "2023", or year-month like "May 2023" / "2023-05" /
"5/2023") and passes them as a [start, end) range. Tight grammar
on purpose so a generic word like "dog" doesn't accidentally
match a date.
- Pulled the response shaper out of `timeline()` into `composeItem`
so both endpoints return the same JSON.
Client
- New `getIndexedSearchPhotos(query, options)` in
`IndexedTimelineSearch.ts`. Same response decoder as the timeline
endpoint, separate cursor namespace so search and timeline don't
fight each other.
- `FetchFilesMixin.fetchFiles` is now a three-way:
1. searchQuery + index ready → `/api/v1/index/search`
2. no search + index ready + no exotic filters → `/api/v1/index/timeline`
3. anything else → DAV REPORT (legacy fallback)
Either indexed path falls back to DAV silently on HTTP failure.
- `TimelineView.getContent` extracts `selectedFilters.name` and
passes it as the `searchQuery` option (DAV `extraFilters` still
carries the equivalent name-filter XML, so older instances on the
DAV-only path keep working).
The legacy DAV `nameFilter` was also broadened in this branch to
match name OR tag OR date — same matching semantics as the new
indexed path. Search input label updated to "Search by name, tag
or date (e.g. 2023, May 2023)".
Build refresh attached.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tiles for browser-playable video formats (mp4 / webm / ogg) now swap their still thumbnail for a muted, looping inline `<video>` when the cursor has been on them for ~250ms. Same affordance Memories and iOS / macOS Photos use — at a glance the timeline animates with what's inside each video, no click required. FileComponent (timeline + favorites + photos + videos + faces + tags + albums + collections + dashboard tiles) - New `<video>` layer at z-index 5 (above the still layers), `object-fit: cover` so motion crops the same way as the thumbnail, brief 240ms fade-in to soften the still→motion swap. - `schedulePreview()` waits 250ms before mounting so a fast cursor sweep across the grid doesn't fire dozens of media loads. Mirrors `iOS Photos` behaviour. - `cancelPreview()` pauses the video, clears the src, and reload()s so the browser releases the buffer instead of holding the (potentially big) video in memory after hover-out. - `prefers-reduced-motion: reduce` opts out — those users keep the still thumbnail. - Codec gating in `isPreviewableVideo` keeps HEIC / HEVC / ProRes / rare codecs on the still path. Those need a transcode pipeline that's a separate, larger change. - Network or codec error → mark the file's preview as failed and don't retry on subsequent hovers (else we'd churn loads forever on broken files). - Cleanup wired into `beforeUnmount`. FileLegacy (folder view tile) - Same affordance, simpler structure: `<video class="video-preview">` pinned absolutely above the existing `<img>` (img is z-index 10, preview is 11). Same delay / cleanup / reduced-motion / codec gating logic. - Hover handlers moved to `.file-legacy-wrap` so the wrap (which already drives the hover-shadow) can also drive the video swap without us double-binding events. Build refresh attached. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ideo
Modern iPhones export HEVC by default and Chrome / Firefox refuse
to play those inline. The hover-autoplay preview that landed in the
previous PR has to fall back to the still thumbnail for those, and
the photos viewer's prev/next breaks with a black square.
Brings up an HLS pipeline that quietly transcodes those clips on
the server so the hover preview (and any future inline player) can
stream a browser-friendly variant. Modeled on Memories' go-vod
sidecar but in-process, no extra dependencies — uses ffmpeg if it's
installed (admins already need it for the Files preview thumbnail
of videos).
DB
- New `oc_photos_transcodes` table: file_id PK, state machine
(`pending` → `transcoding` → `ready` / `failed` / `unsupported`),
`attempts` for backoff, (state, updated_at) index for the
worker's pull query.
- `Version34000Date20260504000000` migration. Goes alongside the
existing photos_index migration; both target NC34.
Service layer
- `PhotoTranscodeMapper` owns the state machine. `claimNextPending`
is atomic (UPDATE WHERE state='pending') so two cron ticks in
parallel don't ffmpeg the same file. `releaseStaleTranscoding`
reaps rows from a worker that died mid-run (>30 min in the
`transcoding` state).
- `PhotoTranscodeService` runs ffmpeg. Single quality preset for
now: H.264 main profile, scale to 720p max, 2 Mbps video + 128
kbps stereo AAC, 6-second segments. Independent_segments +
program_date_time HLS flags so each segment is decodable
standalone.
- Output goes under `<datadir>/photos_transcodes/<fileId>/` —
segments are a cache, not user data, so we bypass IAppData and
write directly to the local filesystem (admins on object-storage
data dirs disable transcoding via the `enable_transcoding`
AppValue).
- Authorisation guard: object storage / non-local files mark
`unsupported` rather than queuing forever (no local path = no
transcode).
- Reuses NC's existing `preview_ffmpeg_path` system config so
admins don't have to set the binary twice; falls back to probing
/usr/bin/ffmpeg, /usr/local/bin/ffmpeg, /opt/homebrew/bin/ffmpeg.
Worker
- `PhotoTranscodeJob` (TimedJob, 5-min interval, 30-min wall budget
per tick). Loops `claimNextPending` → `transcodeFile` until the
budget runs out or the queue empties.
Listener wiring
- `PhotoIndexNodeListener.onWritten` now also marks pending in the
transcode mapper for any video with a non-browser-playable mime
(mp4/webm/ogg are filtered out — those don't need transcoding).
- `onDeleted` deletes the transcode row + on-disk segments alongside
the index row, so the cache doesn't outlive the source file.
API
- `GET /apps/photos/api/v1/transcode/{fileId}/master.m3u8` →
the manifest. `application/vnd.apple.mpegurl`, 1h browser cache.
- `GET /apps/photos/api/v1/transcode/{fileId}/seg-{N}.ts` →
individual segment. `video/mp2t`, 1d cache (segment names are
content-addressed by N + manifest, so safe to cache long).
- 404 when the file isn't ready yet (or its codec wasn't
supported); the client falls back to the still thumbnail / opens
the original in the viewer.
- Authorisation gate: `userFolder->getById($fileId)` — if the user
can't reach the file, the API returns 404 (no probing other
users' libraries).
Out of scope for this PR — follow-ups
- Client wiring: the hover preview isn't yet pointing at the HLS
manifest. That's a small, separate change that needs hls.js as
a dependency for Chrome/Firefox (Safari plays HLS natively).
- Adaptive bitrate ladder: currently a single 720p variant. Once
we have telemetry on devices that play badly, adding 480p / 1080p
variants is straightforward (multiple ffmpeg outputs to a master
manifest with #EXT-X-STREAM-INF entries).
- Hardware acceleration: NVENC / VA-API / VideoToolbox via ffmpeg's
`-hwaccel` flag. Big speedup on machines that have a GPU.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Force NC's `app:upgrade` to pick up the new schema migrations and background-job registrations from the perf-index / search-indexed / hls-transcoding branches. The version bump is the trigger that makes `occ app:enable photos` (and `occ upgrade`) re-run the migration loader for this app. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Edit metadata…" entry to the per-photo overflow menu.
Lets the user correct the two EXIF fields users actually edit in
practice: capture date (cameras with the wrong clock; scanned old
photos) and GPS location (phones without GPS; corrections).
The edits don't touch the original file. They live in a new
photos-app table and overlay at read time, so:
- the original on disk stays byte-identical
- each user's overrides are private (bob's edit on a shared photo
doesn't change what alice sees)
- a Reset button per field falls back to the EXIF / mtime value
Backend
- New `oc_photos_metadata_edits` table: composite PK
`(user_id, file_id)`, NULLable `taken_at` / `gps_lat` / `gps_lng`.
Lat/lng stored as DECIMAL(9,6) — six decimals = ~11 cm at the
equator, plenty for hand-corrected locations, no float-precision
drift. Index on `file_id` alone for the future delete listener.
- `PhotoMetadataEditMapper` upsert + per-file lookup + bulk lookup
for a page of fileIds. Direct DB access (composite PK is
incompatible with QBMapper).
- `MetadataEditController` exposes:
GET /api/v1/metadata/{fileId} → current overrides
PUT /api/v1/metadata/{fileId} → patch (omitted = unchanged,
null = clear).
Authorisation via `userFolder->getById($fileId)` so users can't
probe other users' libraries.
- `IndexController.composeItem` now layers the override on top of
the EXIF view: edited `taken_at` becomes both the response's
top-level `takenAt` (timeline sort key) AND the metadata bag's
`photos-original_date_time` (display key), so the photos UI
stays internally consistent. Edited GPS replaces the EXIF GPS
array entirely.
- On save the controller also pushes the resolved `taken_at` into
`oc_photos_index.taken_at` for that user's row, so the timeline
ORDER BY picks up the edit immediately without a reload.
Frontend
- `PhotoMetadataEditService.ts` thin REST wrapper. Patch shape:
omitted field = leave alone, `null` field = clear override.
- `MetadataEditDialog.vue` form-based dialog with a
`<input type="datetime-local">` for date and two NcTextField
inputs for lat/lng. Live validation (range checks fire as the
user types; Save stays disabled while values are out of range).
Per-field "Reset to original" buttons that clear the stored
override and re-seed from the EXIF defaults the caller passed.
- `PhotoActionsMenu` gets a new "Edit metadata…" entry next to
"View metadata", with the same icon-on-the-left layout as the
other actions. Mounted lazily so the API call only fires when
the user actually opens it.
Out of scope (follow-ups)
- Map picker for GPS (latitude/longitude inputs are functional but
not a great UX for non-technical users). Drop-in component
candidates: `vue-leaflet` (already a dep on the map view) wired
to a draggable marker.
- Title / description / orientation fields. Those are XMP, not
EXIF, and need a different storage path because NC indexes them
separately.
- Bulk edit (apply the same date offset to a multi-selection,
useful for scanned albums where every photo is wrong by the
same amount).
- Writing the overrides back to the file's actual EXIF (would
require shelling out to exiftool / ffmpeg). Current overlay
approach is non-destructive — that's a feature for now, but
power users may eventually want both.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first scroll over a fresh library is the slowest user-facing
moment in photos: every visible tile fires `/api/v1/preview/{fileId}`
cold, NC re-encodes the thumbnail on demand at 64×64 and 1024×1024,
and a few dozen parallel ~200-500ms preview generations stutter the
load. After that the NC preview cache kicks in and everything is
fast.
Drains that cost into a background job so the user never sees it.
Schema
- New `oc_photos_preview_warmup` table — file_id PK, state
(pending → warming → warmed / failed), attempts, updated_at.
PK is per-file (not per-user) because NC's preview cache is
global; warming once covers every user that mounts the file.
Service
- `PhotoPreviewWarmupMapper` mirrors `PhotoTranscodeMapper` but
hands back batches of fileIds rather than one at a time —
warming is sub-second per file so per-file claim round trips
would dominate the wall budget.
- `PhotoPreviewWarmupService::warmFile` calls
`IPreview::getPreview` at exactly the two sizes FileComponent
asks for (64 and 1024). Idempotent: re-warming an already-warm
file is a stat-and-return.
- `PREVIEW_SIZES` is the contract — if photos changes the layer
sizes in FileComponent, this constant has to follow or the
warmup misses.
- Disabled via `enable_preview_warmup` AppValue (default `true` —
the perf win is the whole point). Admins on tight quotas
(warming a 100k library adds ~3-5 GB to NC's appdata preview
cache) leave it off.
Worker
- `PhotoPreviewWarmupJob` (TimedJob, 5-min interval, 5-min wall
budget). Per tick: reap stale `warming` rows from a dead worker
→ backfill new pending rows from `oc_photos_index` (one-time
bootstrap; the listener takes over for new uploads) → drain
the queue in batches of 50.
Listener
- `PhotoIndexNodeListener.onWritten` now also marks the file
pending warmup (idempotent — `markPending` is a no-op for
already-pending / warmed files).
- `onDeleted` drops the warmup row alongside the index + transcode
cleanup.
Bumped app version to 7.0.0-dev.2 so `occ upgrade` re-runs the
migration loader and picks the new table + job up.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related performance changes that show up most when navigating
between Photos / Videos / Favorites tabs and on big libraries.
Service-worker runtime cache (vite.config.ts)
- The SW was already registered with one CacheFirst rule for the
preview endpoint. Added strategies for the indexed-read paths
the perf-index branch introduced:
/api/v1/index/timeline → StaleWhileRevalidate (200 entries,
6h TTL). Returns the previous response instantly for tab
transitions while the SW kicks off a fresh fetch in the
background — feels "instant" on repeat visits.
/api/v1/index/search → NetworkFirst with 4s timeout fall-back
(50 entries, 30s TTL). Search wants freshness; the cache is
only there to keep the previous results visible while the
new query lands.
/api/v1/index/status → NetworkFirst with 3s timeout fall-back
(4 entries, 60s TTL). Migration banner needs near-real-time
updates while the backfill runs.
/api/v1/transcode/{id}/seg-N.ts → CacheFirst (2000 entries,
30d TTL). Segments are content-addressed so safe to cache
aggressively.
- Existing preview cache rule generalised to also match
`publicPreview` (was missing on shared-album views).
Denormalised image dimensions on `oc_photos_index`
- New migration adds `width` / `height` (nullable INT UNSIGNED).
- `PhotoIndexService` now reads `photos-size` via
`IFilesMetadataManager` at index time and writes both columns
alongside the existing `taken_at`. NULL until metadata is ready
— the metadata pipeline is async, so a freshly-written file may
hit the listener before its size is computed; a subsequent
NodeWritten / backfill pass fills the columns.
- `extractTakenAt` now delegates to a shared `extractIndexFields`
so the metadata read path is one method, not two slightly-
different ones drifting over time.
- Mapper SELECTs (`getEnrichedTimelineForUser`, `searchUserTimeline`)
return width/height; the GROUP BY adds them to the keyed columns.
- `IndexController.composeItem` reads `photos-size` from the row
first, falls back to the metadata blob for old rows that haven't
been re-indexed since the migration. Frontend keeps reading
`attributes['metadata-photos-size'].width/height` — same shape
in the response, just sourced from the index table.
Bumped app version to `7.0.0-dev.3` so `occ upgrade` re-runs the
migration loader.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(slideshow): colour-themed chrome — slideshow background eases to the dominant colour of the current photo (extracted from the blurhash already on disk, dimmed 0.55, transitioned via a CSS variable). Apple Music / TV-app aesthetic.
feat: smart auto-albums (Screenshots / Bursts) — two heuristic-classified subsets of the timeline. src/utils/smartAlbums.ts is a pure function over PhotoFile; routes mount TimelineView with a smartAlbumKind prop so all the existing affordances come along.
feat: save burst as animation — src/utils/burstAnimator.ts encodes a stack into a looping WebM via Canvas + MediaRecorder, entirely client-side. Menu entry only renders for stack leaders.
feat: cinematic Memories recap — full-screen CinematicRecap.vue with Ken-Burns pan/zoom on every frame, 1.2s cross-dissolves, colour-themed chrome, and an auto-title overlay. Drives MemoriesView's trip and year-recap cards.
feat: emoji reactions on photos in shared albums — oc_photos_reactions table + ReactionsController toggle endpoint + ReactionBar.vue (six-emoji picker with optimistic updates). Mounted into the in-app slideshow when opened with albumId.
feat: activity feed for shared albums — oc_photos_activity append-only table + ActivityController + ActivityFeed.vue sidebar component. ReactionsController already logs reaction events; file add/remove hooks are a small follow-up.