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();