Skip to content

feat(photos): harden timeline and native media rendering - #249

Merged
veryCrunchy merged 3 commits into
mainfrom
fix/photos-search-owner-isolation
Jul 29, 2026
Merged

feat(photos): harden timeline and native media rendering#249
veryCrunchy merged 3 commits into
mainfrom
fix/photos-search-owner-isolation

Conversation

@veryCrunchy

@veryCrunchy veryCrunchy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Outcome

This pass replaces fragile, overlapping photo discovery with Memories-native timeline and folder flows, while keeping bounded WebDAV compatibility paths for servers that need them. It also adds progressive original-quality image loading, RAW/TIFF handling, media information, and a bounded Android video compatibility fallback.

What changed

  • page the main Photos timeline through the Memories day index and visible-day batches
  • add a full-height edge scrubber with smooth drag, lightly snapped dates, bounded placeholders, and remote month navigation
  • browse folders through the dedicated Memories folder endpoints with explicit direct and recursive scopes, root discovery, search, view modes, and photo mosaics
  • isolate timeline and folder search ownership so one consumer cannot invalidate another
  • keep ordinary photos usable when optional RAW discovery is unsupported, unavailable, or temporarily fails, while retaining an exact RAW retry cursor
  • decode and cache bounded TIFF previews and render RAW sources through Memories or embedded previews
  • load original-quality image detail progressively after a fast preview
  • expose useful format, dimensions, EXIF, RAW, and TIFF information in the viewer
  • allow selecting RAW members from grouped media without losing stack navigation
  • retain a Live Photo still when motion playback is unavailable and attempt a bounded Android LibVLC compatibility path for unsupported HEVC motion streams
  • add deterministic tests and synthetic Compose captures for paging, folder browsing, retry states, decoding, Live Photo fallback, and adaptive navigation

Root causes addressed

Timeline and folder consumers shared SearchDAV carryover state and could start overlapping requests. Optional RAW enrichment was also allowed to turn a successful ordinary-media load into a fatal screen error. Large or uncommon images depended too heavily on server thumbnails and platform-default decoding, while decoder capability failures did not have a bounded native compatibility path.

Validation

  • repository diff and ASCII text hygiene checks passed
  • focused timeline paging and capture scenario tests passed
  • Android compile, unit tests, debug APK assembly, and release lint gate passed with JDK 21
  • full website tests, capture verification, production build, SSR build, and prerender passed
  • 27 synthetic Compose visual QA captures are current, including RAW retry, TIFF, Live Photo fallback, timeline, and folder states
  • the exact head APK was installed on the authenticated Android emulator
  • compact and large-mobile authenticated QA confirmed the real timeline, full-width list, root folder mosaics, adaptive navigation, RAW/TIFF entries, and no WebDAV, coroutine, crash, or out-of-memory signatures

Closes #248
Advances #242
Advances #243
Advances #95
Advances #84
Advances #86
Advances #83
Advances #74
Advances #182

@veryCrunchy veryCrunchy added area:photos Photos, Memories, RAW, people, albums, tags, and editing area:media Media backup, MediaStore, storage reclaim, and sharing labels Jul 28, 2026
@veryCrunchy veryCrunchy added this to the 0.6 Platform polish milestone Jul 28, 2026
@veryCrunchy veryCrunchy added area:ux Navigation, accessibility, responsive design, and interaction platform:android Android-specific implementation or validation platform:desktop Desktop-specific implementation or validation priority:P0 Required for a trustworthy mobile replacement or prevents data loss type:feature New product capability type:reliability Correctness, crash prevention, recovery, and data safety type:performance Startup, rendering, network, memory, or cache performance in-progress Implementation is currently active labels Jul 28, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28ff2fcc93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt Outdated
Comment thread ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/Obiente/nc-native/blob/630a6690377479f81465d0e7038035bf9f5cbd9f/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt#L55
P2 Badge Avoid caching previews without a generation

When a DAV or Memories record has no ETag, the nullable value becomes part of a stable cache key, so replacing or editing the remote file under the same file ID leaves loadPreviewCached() returning the old bytes for the rest of the process even after timeline refresh. Memories parsing explicitly permits missing ETags, making this reachable for normal records. Bypass the cache when no trustworthy generation exists, or include another authoritative generation and invalidate it on refresh.

AGENTS.md reference: AGENTS.md:L203-L206

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt Outdated
Comment thread ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ImageDecodeBounds.kt Outdated
Comment thread ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt Outdated
@veryCrunchy veryCrunchy added bug Something isn't working needs-protocol-review Requires protocol or upstream source verification labels Jul 29, 2026 — with ChatGPT Codex Connector
@veryCrunchy
veryCrunchy merged commit b3d119d into main Jul 29, 2026
2 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f353c07139

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return fallback(cursor)
}

val afterDayId = cursor?.let(::decodeMemoriesFolderInventoryCursor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route DAV fallback cursors before Memories decoding

When the folder index succeeds but the initial day-content request falls back, loadPage() returns a DAV page and its cursor while retaining the Memories index in cache. The next request reaches this unconditional Memories decoder with that DAV cursor and fails, making every older folder page unreachable. Dispatch non-Memories cursors directly to fallback, or persist the selected paging source for the generation.

AGENTS.md reference: AGENTS.md:L139-L140

Useful? React with 👍 / 👎.

} else {
MediaTimelineDavCursorPart(boundaryEpochSeconds = null, firstResult = 0)
}
val response = executeOptionalRawSearch(execute, request.body) ?: return pages

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve failed initial RAW partitions for retry

When optional RAW discovery has already loaded one filename-pattern chunk and a later initial SEARCH throws, this return publishes only the accumulated partitions; the encoded cursor contains no entry for the failed or unattempted patterns, so subsequent pages never retry them and RAW-only originals remain absent until a full refresh. Fresh evidence in this head is that retry cursors are now created only in the decoded-cursor branch via retainRetryCursor(), while this initial-discovery path still returns partial pages. Preserve cursors for the failed and remaining pattern chunks and mark the page retry-pending.

AGENTS.md reference: AGENTS.md:L402-L403

Useful? React with 👍 / 👎.

Comment on lines +469 to +470
require(day.itemCount <= MAX_MEDIA_ITEMS_PER_RESPONSE) {
"A single Memories folder day exceeds the safe media response limit."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Choose DAV before paging folders with oversized days

When a recursive folder index contains a historical day with more than 10,000 items after one or more smaller windows, earlier requests emit Memories cursors, but the request that reaches that day throws here on every retry. All media older than that day therefore remains unreachable. Inspect the complete index before emitting the first Memories cursor and choose DAV fallback for the generation, or implement a bounded within-day cursor.

AGENTS.md reference: AGENTS.md:L139-L140

Useful? React with 👍 / 👎.

Comment on lines +381 to +384
val response = services.executeNextcloudApi(
session,
memoriesPhotoDecodableApiRequest(fileId, etag),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the Memories render for automatic high detail

When the fast preview falls through to loadMemoriesDecodableImageCached() for a RAW or uncommon image, that call already downloads and caches the full decodable-image response, but the automatic high-detail phase immediately executes the identical file-ID/ETag request again here instead of consulting that cache. Opening one such item therefore transfers and retains up to 64 MiB twice before the user interacts with it, contrary to the advertised cache reuse and with avoidable data, battery, and memory cost. Reuse the generation-pinned Memories payload from the preview cache or carry it into the high-detail phase.

AGENTS.md reference: AGENTS.md:L25-L26

Useful? React with 👍 / 👎.

Comment on lines +220 to +222
private fun formatMediaEpochSeconds(epochSeconds: Long): String {
require(epochSeconds >= 0L)
return epochSeconds.toString()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Format capture epochs as user-readable dates

For Memories-backed records when embedded EXIF is unavailable, including every desktop record using the default media-information implementation, the primary Captured field is rendered as a raw value such as 1775000000. Users cannot interpret that as a capture date or time, so format the epoch with the platform-local date, time, and timezone conventions before exposing it in the information dialog.

AGENTS.md reference: AGENTS.md:L64-L66

Useful? React with 👍 / 👎.

}
}
val fileDetails = buildList {
add(MediaInformationField("path", "Path", path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Omit synthetic Memories paths from file information

For timeline, collection, and recursive-folder records where davPathAuthoritative is false, this field exposes presentation-only values such as memories/collections/... or <fileId>-<name> under the user-facing label Path. Desktop always shows that invented location, and Android does so whenever file-ID resolution fails, misleading users into believing it is the object's real Nextcloud path. Include this field only for an authoritative DAV record, or resolve the file identity before presenting a path.

AGENTS.md reference: AGENTS.md:L154-L157

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:media Media backup, MediaStore, storage reclaim, and sharing area:photos Photos, Memories, RAW, people, albums, tags, and editing area:ux Navigation, accessibility, responsive design, and interaction bug Something isn't working in-progress Implementation is currently active needs-protocol-review Requires protocol or upstream source verification platform:android Android-specific implementation or validation platform:desktop Desktop-specific implementation or validation priority:P0 Required for a trustworthy mobile replacement or prevents data loss type:feature New product capability type:performance Startup, rendering, network, memory, or cache performance type:reliability Correctness, crash prevention, recovery, and data safety

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

fix(photos): isolate media searches and keep optional RAW failures nonblocking

1 participant