diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts new file mode 100644 index 00000000000..54c1ff2bde2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.test.ts @@ -0,0 +1,73 @@ +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/store'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { + boardIdSelected, + gallerySliceConfig, + imageSelected, + selectionChanged, +} from 'features/gallery/store/gallerySlice'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { addAutoSwitchedSelectionListener } from './autoSwitchedSelection'; + +// A store with the real gallery reducer and the real listener, so the predicate is exercised +// against actual selection-writing actions rather than a hand-built state pair. +const buildStore = () => { + const listenerMiddleware = createListenerMiddleware(); + addAutoSwitchedSelectionListener(listenerMiddleware.startListening as unknown as AppStartListening); + return configureStore({ + reducer: { gallery: gallerySliceConfig.slice.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware), + }); +}; + +describe('addAutoSwitchedSelectionListener', () => { + beforeEach(() => { + // The marker is a module singleton; drop anything a previous test left on it. + autoSwitchedImages.settle(null); + }); + + it('keeps the marker when the auto-switch selection lands', () => { + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + expect(autoSwitchedImages.consume('a.png')).toBe(true); + }); + + it('drops the marker once the user selects something else', () => { + // The dead click this exists to prevent: the auto-switch to A never rendered because the user + // clicked B first, so their later click on A must still get its reveal. + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + store.dispatch(imageSelected('b.png')); + store.dispatch(imageSelected('a.png')); + expect(autoSwitchedImages.consume('a.png')).toBe(false); + }); + + it('settles on every action that writes the selection, not just imageSelected', () => { + const store = buildStore(); + + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + store.dispatch(selectionChanged(['b.png'])); + expect(autoSwitchedImages.consume('a.png')).toBe(false); + + autoSwitchedImages.record('c.png'); + store.dispatch(imageSelected('c.png')); + store.dispatch(boardIdSelected({ boardId: 'other', select: { selection: ['d.png'], galleryView: 'images' } })); + expect(autoSwitchedImages.consume('c.png')).toBe(false); + }); + + it('leaves the marker alone when an action does not move the selection', () => { + const store = buildStore(); + autoSwitchedImages.record('a.png'); + store.dispatch(imageSelected('a.png')); + // Selecting the same item again, and a board switch that carries no selection, must not + // discard a marker whose image has not rendered yet. + store.dispatch(imageSelected('a.png')); + store.dispatch(boardIdSelected({ boardId: 'other' })); + expect(autoSwitchedImages.consume('a.png')).toBe(true); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts new file mode 100644 index 00000000000..0b14616f0b3 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection.ts @@ -0,0 +1,27 @@ +import type { AppStartListening } from 'app/store/store'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; + +/** + * Keeps the auto-switch marker scoped to the selection it was recorded for. + * + * onInvocationComplete records the item it is about to auto-switch to, so the viewer's reveal + * effect can tell that handoff apart from a user's gallery click. The marker is only meaningful + * while that selection stands: once the selection moves on, the recorded auto-switch will never + * render, and leaving the marker behind would make the user's next click on that item read as an + * auto-switch and get no reveal. + * + * Matched by state rather than by action type on purpose — the selection is written by several + * reducers (imageSelected, selectionChanged, boardIdSelected, comparedImagesSwapped, + * showVirtualBoardsChanged, logout), and a new one added later would silently escape an + * action-type list, leaving exactly the stale marker this exists to prevent. + */ +export const addAutoSwitchedSelectionListener = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: (_action, currentState, previousState) => + selectLastSelectedItem(currentState) !== selectLastSelectedItem(previousState), + effect: (_action, { getState }) => { + autoSwitchedImages.settle(selectLastSelectedItem(getState()) ?? null); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts new file mode 100644 index 00000000000..f457fac2280 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.test.ts @@ -0,0 +1,60 @@ +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/store'; +import { gallerySliceConfig, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; +import { api } from 'services/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { addBoardIdSelectedListener } from './boardIdSelected'; + +// The listener waits for the board's item list before auto-selecting, so the store needs the API +// slice present (the query is never fulfilled here — the point is what happens meanwhile). +const buildStore = () => { + const listenerMiddleware = createListenerMiddleware(); + addBoardIdSelectedListener(listenerMiddleware.startListening as unknown as AppStartListening); + return configureStore({ + reducer: { + gallery: gallerySliceConfig.slice.reducer, + [api.reducerPath]: api.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }).prepend(listenerMiddleware.middleware), + }); +}; + +describe('addBoardIdSelectedListener', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not overwrite a selection made while it was waiting for the board list', async () => { + // The gallery's auto-switch dispatches galleryViewChanged immediately before imageSelected. + // Without the cancel, the probe this starts wakes up on that very selection and re-selects + // from a stale (or empty) list, undoing the auto-switch — and the viewer then reveals the + // wrong item over the live preview, the flash the auto-switch marker exists to prevent. + const store = buildStore(); + + store.dispatch(galleryViewChanged('images')); + store.dispatch(imageSelected('new.png')); + + // Past the probe's 5s give-up, which would otherwise clear the selection outright. + await vi.advanceTimersByTimeAsync(6000); + + expect(store.getState().gallery.selection).toEqual(['new.png']); + }); + + it('still clears the selection when a board switch finds nothing to show', async () => { + // The auto-select probe itself must keep working: a board change with no items selects + // nothing rather than leaving the previous board's item highlighted. + const store = buildStore(); + store.dispatch(imageSelected('from-previous-board.png')); + + store.dispatch(galleryViewChanged('assets')); + await vi.advanceTimersByTimeAsync(6000); + + expect(store.getState().gallery.selection).toEqual([]); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 05dd8e9f208..588b39881a8 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -6,11 +6,22 @@ import { galleryApi } from 'services/api/endpoints/gallery'; export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { startAppListening({ - matcher: isAnyOf(boardIdSelected, galleryViewChanged), + matcher: isAnyOf(boardIdSelected, galleryViewChanged, imageSelected), effect: async (action, { getState, dispatch, condition, cancelActiveListeners }) => { // Cancel any in-progress instances of this listener, we don't want to select an item from a previous board cancelActiveListeners(); + if (imageSelected.match(action)) { + // An explicit selection settles what should be displayed, so a probe still waiting on a + // board's items must not overwrite it when it resolves. The gallery's auto-switch dispatches + // galleryViewChanged immediately before imageSelected: without this the probe that view + // change starts wakes on the selection that follows it, re-selects the first name in a + // possibly stale cached list, and undoes the switch — and the viewer then reveals that + // wrong image over the live preview, which is the flash the auto-switch marker exists to + // prevent. Cancelling above is the whole effect; there is nothing to auto-select here. + return; + } + if (boardIdSelected.match(action) && action.payload.select) { // This action already has a resource selection - skip the below auto-selection logic return; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 59551225314..7ea4ffbf45c 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -12,6 +12,7 @@ import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; +import { addAutoSwitchedSelectionListener } from 'app/store/middleware/listenerMiddleware/listeners/autoSwitchedSelection'; import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/batchEnqueued'; import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; @@ -326,6 +327,7 @@ addImageAddedToBoardFulfilledListener(startAppListening); addImageRemovedFromBoardFulfilledListener(startAppListening); addBoardIdSelectedListener(startAppListening); addArchivedOrDeletedBoardListener(startAppListening); +addAutoSwitchedSelectionListener(startAppListening); // Node schemas addGetOpenAPISchemaListener(startAppListening); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index ba76ca299bb..dc8331ba74e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -6,6 +6,7 @@ import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { useNextPrevItemNavigation } from 'features/gallery/components/useNextPrevItemNavigation'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -19,11 +20,12 @@ import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { useImageViewerContext } from './context'; +import { SELECTED_ITEM_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; import { ProgressImageTiles } from './ProgressImageTiles'; import { ProgressIndicator } from './ProgressIndicator2'; +import { createSelectedItemRevealController } from './selectedItemReveal'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => { const activeTab = useAppSelector(selectActiveTab); @@ -38,6 +40,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu $activeProgressData, $isProgressImageResolving, $isTemporarilyShowingSelectedImage, + lastRenderedItemNameRef, } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); @@ -45,8 +48,16 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu const isProgressImageResolving = useStore($isProgressImageResolving); const isTemporarilyShowingSelectedImage = useStore($isTemporarilyShowingSelectedImage); const [imageToRender, setImageToRender] = useState(null); - const previousRenderedImageNameRef = useRef(null); - const selectedImageRevealTimeoutId = useRef(0); + // One controller per mounted preview component; the previous-item ref inside it is the shared + // one from the viewer context, so image <-> video clicks read as selection changes on both ends. + const [revealController] = useState(() => + createSelectedItemRevealController({ + lastRenderedItemNameRef, + marker: autoSwitchedImages, + setRevealed: (revealed) => $isTemporarilyShowingSelectedImage.set(revealed), + durationMs: SELECTED_ITEM_REVEAL_DURATION_MS, + }) + ); useEffect(() => { if (!selectedImageName) { @@ -91,41 +102,24 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu const hasProgressImage = progressImage !== null; + // The reveal sequencing (previous-item tracking, auto-switch suppression, resolve-window + // deferral, StrictMode re-arm) lives in the controller — see selectedItemReveal.ts. useEffect(() => { - const renderedImageName = imageToRender?.image_name ?? null; - const previousRenderedImageName = previousRenderedImageNameRef.current; - previousRenderedImageNameRef.current = renderedImageName; - - window.clearTimeout(selectedImageRevealTimeoutId.current); - - if ( - !shouldShowProgressInViewer || - !hasProgressImage || - isProgressImageResolving || - !renderedImageName || - renderedImageName !== selectedImageName - ) { - $isTemporarilyShowingSelectedImage.set(false); - return; - } - - if (previousRenderedImageName === null || previousRenderedImageName === renderedImageName) { - return; - } - - $isTemporarilyShowingSelectedImage.set(true); - selectedImageRevealTimeoutId.current = window.setTimeout(() => { - $isTemporarilyShowingSelectedImage.set(false); - }, SELECTED_IMAGE_REVEAL_DURATION_MS); - + revealController.run({ + shouldShowProgressInViewer, + hasProgressImage, + isProgressImageResolving, + renderedItemName: imageToRender?.image_name ?? null, + selectedItemName: selectedImageName ?? null, + }); return () => { - window.clearTimeout(selectedImageRevealTimeoutId.current); + revealController.clearTimer(); }; }, [ - $isTemporarilyShowingSelectedImage, hasProgressImage, imageToRender?.image_name, isProgressImageResolving, + revealController, selectedImageName, shouldShowProgressInViewer, ]); @@ -237,7 +231,11 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageToRender && !withProgress && ( + {/* Gated on the reveal state itself, not only on !withProgress (which the reveal turns + off): the reveal exists to make a mid-render click visibly land, and the full-screen + metadata panel would drop exactly on top of the just-revealed image for the whole + window. Mirrors CurrentVideoPreview's gate. */} + {shouldShowItemDetails && imageToRender && !isTemporarilyShowingSelectedImage && !withProgress && ( @@ -277,5 +275,3 @@ const exit: AnimationProps['exit'] = { opacity: 0, transition: { duration: 0.07 }, }; - -const SELECTED_IMAGE_REVEAL_DURATION_MS = 2000; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts index 9a22e8cc321..86380c42c96 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.test.ts @@ -11,3 +11,72 @@ describe('CurrentVideoPreview playback errors', () => { expect(source).toContain('onError={handleVideoError}'); }); }); + +describe('CurrentVideoPreview progress overlay', () => { + const source = readFileSync(fileURLToPath(new URL('./CurrentVideoPreview.tsx', import.meta.url)), 'utf8'); + + it('lifts the overlay during the temporary reveal so mid-render thumbnail clicks visibly land', () => { + // The overlay must consult the shared reveal atom (and never re-cover an actively-playing + // video) — an unconditional overlay swallows every gallery click for the whole render. + expect(source).toMatch( + /withProgress =\s+shouldShowProgressInViewer && hasProgressImage && !isTemporarilyShowingSelectedImage && !isPlaying/ + ); + expect(source).toContain('SELECTED_ITEM_REVEAL_DURATION_MS'); + // The previous-item ref handed to the reveal controller must be the shared one from the + // viewer context, so image -> video clicks still read as a selection change after the + // preview component swaps. + expect(source).toMatch(/createSelectedItemRevealController\(\{\s+lastRenderedItemNameRef,/); + }); + + it('tiles concurrent sessions instead of letting them overwrite each other (multi-GPU)', () => { + // CurrentImagePreview tiles per-session previews when several renders run at once; the video + // overlay must do the same or the sessions fight over the single full-size preview slot. + expect(source).toMatch(/withTiledProgress = withProgress && activeProgressData\.length > 1/); + expect(source).toContain(''); + }); + + it('routes the reveal decision through the shared controller with the auto-switch marker', () => { + // The auto-switch selection lands after onInvocationComplete's DTO fetch, so a quickly-started + // next render's first progress event can reset $isProgressImageResolving ahead of it. Timing + // cannot tell that handoff from a gallery click; the marker can, and the controller owns the + // full sequencing (marker consumption, resolve-window deferral, StrictMode re-arm — see + // selectedItemReveal.test.ts for the behavior). + expect(source).toMatch(/marker: autoSwitchedImages,/); + expect(source).toMatch(/revealController\.run\(\{/); + expect(source).toMatch(/isProgressImageResolving,\s+renderedItemName: videoName,/); + // The effect cleanup must only cancel the timer — the next run (or the unmount handler below) + // owns the revealed flag. + expect(source).toMatch(/return \(\) => \{\s+revealController\.clearTimer\(\);\s+\};/); + }); + + it('does not cover playback or a temporary reveal with the metadata panel', () => { + // Playing and revealing both turn withProgress off, so gating the full-screen metadata panel + // on !withProgress alone drops it exactly on top of the native controls / the just-revealed + // video whenever item details are enabled. + expect(source).toMatch( + /shouldShowItemDetails && !isPlaying && !isTemporarilyShowingSelectedImage && !withProgress &&/ + ); + }); + + it('restores the overlay when playback ends on its own, not only when the player is closed', () => { + // isPlaying suppresses the overlay; without onEnded it never falls back, so the live preview + // stays hidden for the rest of the generation after a short video plays out. + expect(source).toContain('onEnded={handleClose}'); + }); + + it('does not end a pending resolve when play() is rejected', () => { + // A rejected play() is not a load failure — the element is intact and its metadata has usually + // already loaded — so it must not clear an overlay belonging to some other session's render. + const playHandler = source.slice(source.indexOf('const handlePlay'), source.indexOf('const handleClose')); + expect(playHandler).toContain('reportPlaybackFailure()'); + expect(playHandler).not.toContain('onLoadImage'); + }); + + it('ends a pending post-render resolve when the video element errors', () => { + // onLoadedMetadata normally ends the resolve illusion; an errored element never fires it. The + // call must carry this video's session id so the lifecycle can tell it apart from a late load + // belonging to another concurrently-completed session. + const errorHandler = source.slice(source.indexOf('const handleVideoError'), source.indexOf('const handlePlay')); + expect(errorHandler).toContain('onLoadImage(videoDTO?.session_id ?? null)'); + }); +}); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx index 19aacb1944b..59983190089 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx @@ -13,7 +13,12 @@ import { dndInputFix } from 'features/dnd/util'; import VideoMetadataViewer from 'features/gallery/components/ImageMetadataViewer/VideoMetadataViewer'; import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { useNextPrevItemNavigation } from 'features/gallery/components/useNextPrevItemNavigation'; -import { selectSelectedBoardId, selectSelection } from 'features/gallery/store/gallerySelectors'; +import { autoSwitchedImages } from 'features/gallery/store/autoSwitchedImages'; +import { + selectLastSelectedItem, + selectSelectedBoardId, + selectSelection, +} from 'features/gallery/store/gallerySelectors'; import { isVideoName } from 'features/gallery/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { toast } from 'features/toast/toast'; @@ -30,10 +35,12 @@ import { useTranslation } from 'react-i18next'; import { PiArrowSquareOutBold, PiCopyBold, PiDownloadSimpleBold, PiTrashSimpleBold, PiXBold } from 'react-icons/pi'; import type { VideoDTO } from 'services/api/types'; -import { useImageViewerContext } from './context'; +import { SELECTED_ITEM_REVEAL_DURATION_MS, useImageViewerContext } from './context'; import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; +import { ProgressImageTiles } from './ProgressImageTiles'; import { ProgressIndicator } from './ProgressIndicator2'; +import { createSelectedItemRevealController } from './selectedItemReveal'; import { VideoPlayButtonOverlay } from './VideoPlayButtonOverlay'; type Props = { @@ -57,6 +64,8 @@ type Props = { * appear on top of the previously-loaded video. Without this, a freshly generated render's * progress images had nowhere to display whenever a video was the last-selected gallery * item (and the user only saw the static first-frame still until the new video finished). + * Also mirrors its temporary reveal: clicking a gallery thumbnail mid-render lifts the + * overlay briefly so the click visibly lands, then the live preview returns. */ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => { const videoUrl = useMediaUrl(videoDTO?.video_url); @@ -71,17 +80,81 @@ export const CurrentVideoPreview = memo(({ videoDTO }: Props) => { const deleteVideoModal = useDeleteVideoModalApi(); const { downloadItem } = useDownloadItem(); const clipboard = useClipboard(); - const { $progressEvent, $progressImage, onLoadImage } = useImageViewerContext(); + const { + $progressEvent, + $progressImage, + $activeProgressData, + $isProgressImageResolving, + $isTemporarilyShowingSelectedImage, + lastRenderedItemNameRef, + onLoadImage, + } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); - const withProgress = shouldShowProgressInViewer && progressImage !== null; + const activeProgressData = useStore($activeProgressData); + const isProgressImageResolving = useStore($isProgressImageResolving); + const isTemporarilyShowingSelectedImage = useStore($isTemporarilyShowingSelectedImage); + const hasProgressImage = progressImage !== null; + // `!isPlaying`: a reveal exposes the play button, and an explicit play is a stronger signal than + // the click that triggered the reveal — never re-cover an actively-playing video with the opaque + // overlay (its audio would keep running underneath, with the controls unreachable). The overlay + // returns when playback ends — whether the user closes the player or the video runs out. + const withProgress = + shouldShowProgressInViewer && hasProgressImage && !isTemporarilyShowingSelectedImage && !isPlaying; + // When more than one session is generating concurrently (multi-GPU), tile their previews instead + // of letting the sessions overwrite each other's full-size preview. Mirrors CurrentImagePreview. + const withTiledProgress = withProgress && activeProgressData.length > 1; const { goToPreviousImage, goToNextImage, isFetching } = useNextPrevItemNavigation(); + const selectedItemName = useAppSelector(selectLastSelectedItem); + // One controller per mounted preview component; the previous-item ref inside it is the shared + // one from the viewer context, so image <-> video clicks read as selection changes on both ends. + const [revealController] = useState(() => + createSelectedItemRevealController({ + lastRenderedItemNameRef, + marker: autoSwitchedImages, + setRevealed: (revealed) => $isTemporarilyShowingSelectedImage.set(revealed), + durationMs: SELECTED_ITEM_REVEAL_DURATION_MS, + }) + ); // Whenever the selected video changes, drop back to the idle still + play overlay. useEffect(() => { setIsPlaying(false); }, [videoName]); + // Mid-generation gallery clicks: mirror CurrentImagePreview's temporary reveal. Without this, + // the opaque progress overlay swallows every video-thumbnail click for the whole render — the + // selection changes underneath, but nothing visibly happens. Unlike the image path there is no + // preload step: the