From 8beb7331dd3386759694e1dc0bcf5fd2ef63e0c9 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 6 May 2026 19:24:50 -0400 Subject: [PATCH] fix(home): preview tracks frame state even with locked images and 304 polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems were stacked: 1. The 200 serving path didn't set currentImage when a locked image was served (RotationService.advance bypassed). The frame got the locked photo; the DB kept the previous one; Home showed the old one. 2. The 304 path didn't flush at all. lastSeenAt (markSeen) was lost on every no-change poll, and any drift in currentImage couldn't self-heal. For a frame that's been locked for a while, polls cycle as 304 forever and the DB stays wrong indefinitely. 3. Pull-to-refresh fetched via fetchDevices(), which flips loading=true and replaces the cards with "Loading…" mid-fetch. The PTR spinner was working but users couldn't see the result of their refresh. Fixes: - Both 200 and 304 paths now set currentImage = $image and flush. The 304 path becomes self-healing for any device whose currentImage drifted from reality (e.g., from before the 200-path fix). - fetchDevices / fetchImages take an optional { silent: true } that skips toggling loading.value. PTR refresh callbacks pass silent so the cards stay visible during background refresh. - HomeView also listens on visibilitychange and silently re-fetches when the PWA returns to foreground, so reopening the app shows current state without a manual pull. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/devices.ts | 9 ++++++-- frontend/src/stores/images.ts | 9 ++++++-- frontend/src/views/HomeView.vue | 18 +++++++++++++-- frontend/src/views/LibraryView.vue | 4 ++-- .../build/assets/BaseBottomSheet-lUhdoq2-.js | 1 + .../build/assets/BaseBottomSheet-vZ7hFF0Y.js | 1 - ...r-BAb-kxko.js => DevicePicker-DB6TSbzz.js} | 2 +- public/build/assets/HomeView-B1mPAJMq.js | 1 + public/build/assets/HomeView-CIMPKeWy.js | 1 - ...iew-CSdCe5ba.css => HomeView-CaEZqvVK.css} | 2 +- public/build/assets/LibraryView-D_UZfGD5.js | 1 - ...-kUqy3usw.css => LibraryView-jkY9po1z.css} | 2 +- public/build/assets/LibraryView-olQux25G.js | 1 + ...w-CPVpIA6P.js => SettingsView-BHhuChZl.js} | 2 +- ...iew-BYIjcQti.js => UploadView-yzz3wNGy.js} | 2 +- .../{index-BGc6BnG_.js => index-DwuxDERh.js} | 4 ++-- public/build/index.html | 2 +- src/Controller/DeviceImageController.php | 22 +++++++++++++++++-- 18 files changed, 63 insertions(+), 21 deletions(-) create mode 100644 public/build/assets/BaseBottomSheet-lUhdoq2-.js delete mode 100644 public/build/assets/BaseBottomSheet-vZ7hFF0Y.js rename public/build/assets/{DevicePicker-BAb-kxko.js => DevicePicker-DB6TSbzz.js} (92%) create mode 100644 public/build/assets/HomeView-B1mPAJMq.js delete mode 100644 public/build/assets/HomeView-CIMPKeWy.js rename public/build/assets/{HomeView-CSdCe5ba.css => HomeView-CaEZqvVK.css} (85%) delete mode 100644 public/build/assets/LibraryView-D_UZfGD5.js rename public/build/assets/{LibraryView-kUqy3usw.css => LibraryView-jkY9po1z.css} (61%) create mode 100644 public/build/assets/LibraryView-olQux25G.js rename public/build/assets/{SettingsView-CPVpIA6P.js => SettingsView-BHhuChZl.js} (92%) rename public/build/assets/{UploadView-BYIjcQti.js => UploadView-yzz3wNGy.js} (98%) rename public/build/assets/{index-BGc6BnG_.js => index-DwuxDERh.js} (95%) diff --git a/frontend/src/stores/devices.ts b/frontend/src/stores/devices.ts index 3bbecec..fc8322f 100644 --- a/frontend/src/stores/devices.ts +++ b/frontend/src/stores/devices.ts @@ -7,8 +7,13 @@ export const useDevicesStore = defineStore('devices', () => { const loading = ref(false) const error = ref(null) - async function fetchDevices() { - loading.value = true + /** + * Fetch the device list. Pass `silent: true` from background refreshes + * (pull-to-refresh, visibility-change polling) so the loading spinner + * doesn't blink and replace the existing cards mid-fetch. + */ + async function fetchDevices(opts: { silent?: boolean } = {}) { + if (!opts.silent) loading.value = true error.value = null try { const res = await fetch('/api/devices') diff --git a/frontend/src/stores/images.ts b/frontend/src/stores/images.ts index af29240..e23a151 100644 --- a/frontend/src/stores/images.ts +++ b/frontend/src/stores/images.ts @@ -15,8 +15,13 @@ export const useImagesStore = defineStore('images', () => { const error = ref(null) const pendingCount = ref(0) - async function fetchImages() { - loading.value = true + /** + * Fetch the image list. Pass `silent: true` from background refreshes + * (pull-to-refresh) so the loading spinner doesn't blink and replace the + * existing grid mid-fetch. + */ + async function fetchImages(opts: { silent?: boolean } = {}) { + if (!opts.silent) loading.value = true error.value = null try { const res = await fetch('/api/images') diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 7b52211..87471d2 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -117,7 +117,7 @@ + diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 0a757a1..426a3ad 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -100,6 +100,16 @@ class DeviceImageController extends AbstractController && $device->getCurrentImageOrientation() === $device->getOrientation() && $renderedAt !== null && $device->getCurrentRenderedAt()?->getTimestamp() === $renderedAt->getTimestamp()) { + // Self-heal currentImage: locked-image polls bypass advance() which + // would otherwise have set this. Without the assignment, currentImage + // stays stale — Home would keep showing the previous photo even + // though the device has been confirming the new one for cycles. + // Also flush so markSeen() above is persisted on every 304 (lastSeenAt + // would otherwise freeze whenever the device polls and gets no + // change, causing the status badge to drift to "offline"). + $device->setCurrentImage($image); + $em->flush(); + $this->logger->info('device.poll.no_change', [ 'device_id' => $device->getId(), 'mac' => $mac, @@ -125,8 +135,16 @@ class DeviceImageController extends AbstractController return $r; } - // Record the orientation and rendered_at we're serving at so the next - // poll's 304 check can detect a flip or a re-render and force a re-fetch. + // Record what the device is now showing, plus the orientation and + // rendered_at we served at (so the next 304 check can detect a flip + // or a re-render and force a re-fetch). + // + // currentImage must be set here for the locked-image path: rotation + // is bypassed when a lock is in effect, so RotationService.advance() + // never runs to update currentImage. Without this assignment, Home + // would keep showing the previous photo even after the device pulled + // the locked one. + $device->setCurrentImage($image); $device->setCurrentImageOrientation($device->getOrientation()); $device->setCurrentRenderedAt($renderedAt); $em->flush();