Skip to content

feat(screensaver): add real-time interactive photo drag & direction-aware slide transition - #166

Open
miguelsg29 wants to merge 9 commits into
starbrightlab:mainfrom
miguelsg29:feature/screensaver-swipe-transition
Open

feat(screensaver): add real-time interactive photo drag & direction-aware slide transition#166
miguelsg29 wants to merge 9 commits into
starbrightlab:mainfrom
miguelsg29:feature/screensaver-swipe-transition

Conversation

@miguelsg29

Copy link
Copy Markdown
Contributor

Summary of Changes

This PR introduces real-time interactive touch gesture navigation and direction-aware slide transitions to the screensaver (PhotoFrameController):

1. Interactive 1:1 Touch Drag Navigation (ACTION_MOVE)

  • Real-Time Drag Tracking: As the user drags horizontally across the screensaver, the current photo and adjacent photo move in 1:1 real-time sync with finger movement.
  • Zero-Gap Contiguous Layout: Positioned incoming adjacent photos edge-to-edge with the current photo, eliminating black gaps during drag interactions (carousel / filmstrip effect).
  • Smooth Release Momentum: Releasing the drag smoothly continues the slide from the exact release position to completion using DecelerateInterpolator, or snaps back cleanly if released under threshold.

2. Background Adjacent Photo Preloading (LruCache)

  • Instant Swipe Performance: Integrated an in-memory LruCache<String, Bitmap> for preloading adjacent (next & previous) photos on a background thread pool immediately after any photo displays.
  • Zero Latency: Eliminates disk and network decoding delays during swipe gestures.

3. Preserved Auto-Dwell Transition

  • Automatically changing photos over time continues to use the standard 900ms crossfade alpha transition.

Verification & Unit Tests

  • Passes all unit tests: ./gradlew :app:testDebugUnitTest (BUILD SUCCESSFUL).
  • Verified real-time gesture dragging, zero-gap carousel transitions, and instant preloading on physical Meta Portal Go hardware (./gradlew :app:assembleDebug).

@starbrightlab starbrightlab left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this — the gesture/animation work is genuinely nicely built (the slide-completion math resuming from the live drag translationX and the snap-back reset are clean). But there are two blocking issues that would bite on real Portal hardware, so requesting changes.

Blockers

1. Main-thread I/O in the touch handler → crash. setupAdjacentDragBitmap() is called from onTouch ACTION_MOVE (main thread), and on a cache miss it falls back to a synchronous decode/fetch (PhotoFrameController.kt:246,251). For remote sources that's fetchRemoteImage on the UI thread → NetworkOnMainThreadException → launcher crash (and a crash on a home app clears the HOME preferred-activity association). It fires on the first swipe of any neighbor whose preload hasn't landed. The fallback must run on the io executor with the layer wired up in the posted ui continuation — never inline in ACTION_MOVE.

2. LruCache(8) is entry-count sized → OOM. LruCache<String,Bitmap>(8) (PhotoFrameController.kt:214) has no sizeOf override, so it's 8 entries regardless of pixel size. A 12MP original at inSampleSize=1 ≈ 48 MB ARGB_8888 × 8 ≈ ~300 MB retained, on top of the live layers + blur copies. That's a guaranteed OOM/heavy-GC regression on the RAM-limited Portal. Size the cache in bytes as a fraction of Runtime.maxMemory() (override sizeOfbitmap.byteCount) and cap to prev/current/next (2–3 entries).

Should-fix

  1. Dwell timer races the dragACTION_DOWN/MOVE never cancel the auto-advance tick, so it can flip activeLayerIndex / reset translationX mid-drag and move the wrong layer. Suspend the tick on ACTION_DOWN/while dragging, reschedule on UP/snap-back.
  2. ACTION_CANCEL can trigger dismiss — UP and CANCEL share a branch (:300); a CANCEL with small dx/dy falls through to the tap test and calls onExit. Gate onExit to ACTION_UP only.
  3. Black gap on incomplete preload — if the incoming bitmap is null, the code still sets incomingLayer translation and the UP slide animates a blank/stale layer in. Guard the commit unless a real incoming bitmap was set.

Nits: no multi-touch/pointer-id handling; the index math + slide-duration calc are pure and worth a small unit test.

Happy to help once these are addressed — the core UX is worth landing.

@miguelsg29
miguelsg29 force-pushed the feature/screensaver-swipe-transition branch from d664ec0 to 72e04ad Compare August 11, 2026 17:34
@miguelsg29

Copy link
Copy Markdown
Contributor Author

PR #166 has been rebased on top of current main (v1.67)!

All requested review items are included:

  • Moved drag image fetching off main thread to async IO executor.
  • Bounds-checked LruCache by memory byteCount (max 32 MB) to prevent OOM.
  • Added VelocityTracker for natural flick gestures with a DecelerateInterpolator(1.8f) curve.
  • Suspended auto-advance timer during active touch drag.
  • Gated ACTION_CANCEL touch events.
  • Verified on physical Portal hardware. Ready for review!

@starbrightlab starbrightlab left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I test-merged this against current main (it merges cleanly even after #202 touched the same file) and it builds with 333 tests green. The gesture work reads well — the 1:1 drag, the release momentum, and keeping the auto-dwell crossfade separate from the interactive slide are all nicely done. One thing needs fixing before it goes in.

The preload cache has a floor, not a cap

// Sized by memory byte count (max 1/8th of available max heap, capped between 16MB and 32MB) to prevent OOM.
val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSizeKb = (maxMemoryKb / 8).coerceIn(16 * 1024, 32 * 1024)

coerceIn(min, max) raises the value when it's below the minimum, so this can never be smaller than 16MB — the opposite of what the comment describes on exactly the devices that need it smallest. If 1/8 of the heap is 8MB, this asks for 16MB anyway.

That matters here because the app doesn't request largeHeap, so on Android 9/10 Portals the per-app heap can be modest, and the screensaver is the thing left running unattended for hours. 16MB of decoded bitmaps is on top of the two full-screen ImageView layers the frame already holds plus the blur copies — and #202 just landed a setting that keeps a blurred bitmap around in fit mode.

The fix is a one-liner — cap rather than floor:

val cacheSizeKb = (maxMemoryKb / 8).coerceAtMost(32 * 1024)

with a small floor only if you want one (coerceIn(4 * 1024, 32 * 1024)), which would then match the comment.

Portal Go, where you verified this, is one of the roomier models — worth a run on a Gen-1 Portal or Mini after the change, ideally with a large local folder so the cache actually fills.

Minor

.gitignore gains referencias_privado, which looks like a personal working directory rather than something the project needs. Better in your global gitignore or .git/info/exclude than in the repo's.

Happy to re-check once the sizing is fixed — everything else here looked sound to me, including velocityTracker being recycled on all three exit paths.


Generated by Claude Code

@miguelsg29

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — both fixed and pushed.

  • Cache sizing is now .coerceIn(4 * 1024, 32 * 1024), and I updated the comment above it so it describes what the code actually does (it previously described a cap while implementing a floor).
  • referencias_privado is out of the repo .gitignore and now lives in my local .git/info/exclude.

I pushed on top of the existing base rather than rebasing, so the clean test-merge you did still holds. Unit tests green locally.

One thing worth your call on the floor: a single full-screen ARGB_8888 bitmap on a 1280×800 Portal is ~4.1 MB, so a fixed 4 MB floor sits just under one entry. If sizeOf for a single entry exceeds maxSize, LruCache.put() trims it straight back out — no crash, no log, the cache just stays empty and every swipe falls through to the async path. Would you prefer the floor expressed as one screen-sized bitmap rather than a fixed 4 MB? Happy either way.

On device testing: I only have a Portal Go, so I can't verify Gen-1 or Mini myself. If it's useful I can push a one-line debug log of maxMemory() and the resulting cache size at screensaver start, so anyone on those models can report real numbers — say the word.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants