diff --git a/.changeset/lightbox-pause-on-close.md b/.changeset/lightbox-pause-on-close.md new file mode 100644 index 0000000000000..c3f3f1866407d --- /dev/null +++ b/.changeset/lightbox-pause-on-close.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Closing a Lightbox stops a video that was playing. The viewer stays mounted, so the picture and its sound would otherwise continue after Close, Escape, or a tap outside. A viewer that is already closed does not start playback either. Opening it again plays the current video when `hasAutoPlay` is set. + +@SebTardif diff --git a/packages/core/src/Lightbox/Lightbox.test.tsx b/packages/core/src/Lightbox/Lightbox.test.tsx index 937f28911e5e5..5f217cf6f0c3c 100644 --- a/packages/core/src/Lightbox/Lightbox.test.tsx +++ b/packages/core/src/Lightbox/Lightbox.test.tsx @@ -598,6 +598,103 @@ describe('Lightbox', () => { expect(pause).toHaveBeenCalledTimes(1); }); + it('pauses a playing video when the viewer closes', () => { + const items = [ + {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}, + {src: '/b.jpg', alt: 'Image B'}, + ]; + const view = (isOpen: boolean) => ( + {}} + media={items} + index={0} + /> + ); + const {rerender} = render(view(true)); + const video = document.querySelector('video')!; + // Started by someone: playing, with its sound. Closing keeps the + // element mounted inside the closed dialog. + const pause = vi.fn(); + video.pause = pause; + Object.defineProperty(video, 'paused', { + configurable: true, + get: () => pause.mock.calls.length > 0, + }); + rerender(view(false)); + expect(document.querySelector('video')).toBe(video); + expect(pause).toHaveBeenCalledTimes(1); + }); + + it('pauses a single playing video when the viewer closes', () => { + const media = {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}; + const view = (isOpen: boolean) => ( + {}} media={media} /> + ); + const {rerender} = render(view(true)); + const video = document.querySelector('video')!; + const pause = vi.fn(); + video.pause = pause; + Object.defineProperty(video, 'paused', { + configurable: true, + get: () => pause.mock.calls.length > 0, + }); + rerender(view(false)); + expect(document.querySelector('video')).toBe(video); + expect(pause).toHaveBeenCalledTimes(1); + }); + + it('plays a single video again when the viewer reopens, with hasAutoPlay', () => { + const play = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockImplementation(async () => {}); + try { + const media = {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}; + const view = (isOpen: boolean) => ( + {}} + media={media} + hasAutoPlay + /> + ); + const {rerender} = render(view(true)); + expect(play).toHaveBeenCalledTimes(1); + const video = document.querySelector('video')!; + const pause = vi.fn(); + video.pause = pause; + Object.defineProperty(video, 'paused', { + configurable: true, + get: () => pause.mock.calls.length > 0, + }); + rerender(view(false)); + expect(pause).toHaveBeenCalledTimes(1); + rerender(view(true)); + expect(play).toHaveBeenCalledTimes(2); + expect(play.mock.contexts.at(-1)).toBe(video); + } finally { + play.mockRestore(); + } + }); + + it('withholds autoplay while the viewer is mounted closed', () => { + const media = {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}; + const view = (isOpen: boolean) => ( + {}} + media={media} + hasAutoPlay + /> + ); + const {rerender} = render(view(false)); + // The element stays mounted. The attribute would start playback once + // the file loads, after the close effect has already run. + expect(document.querySelector('video')).not.toHaveAttribute('autoplay'); + rerender(view(true)); + expect(document.querySelector('video')).toHaveAttribute('autoplay'); + }); + it("keeps a paged-away video's element and time once the viewer pages back", () => { const items = [ {src: '/clip.mp4', alt: 'A clip', type: 'video' as const}, diff --git a/packages/core/src/Lightbox/Lightbox.tsx b/packages/core/src/Lightbox/Lightbox.tsx index a219aa915f650..485e340dbc463 100644 --- a/packages/core/src/Lightbox/Lightbox.tsx +++ b/packages/core/src/Lightbox/Lightbox.tsx @@ -623,11 +623,23 @@ export function Lightbox({ // A video left on another page stops: the pager keeps the neighbours // mounted, and a playing one would go on, with its sound, off screen. With // `hasAutoPlay`, the video paged to plays again, as a newly shown one does. + // The closed dialog keeps them mounted too, so closing stops a playing + // video. A single video has no page element. useEffect(() => { const container = containerRef.current; if (container == null) { return; } + if (!isOpen) { + for (const video of container.querySelectorAll( + 'video', + )) { + if (!video.paused) { + video.pause(); + } + } + return; + } for (const video of container.querySelectorAll( '[data-lightbox-page]:not([data-current]) video', )) { @@ -635,14 +647,17 @@ export function Lightbox({ video.pause(); } } - const current = container.querySelector( - '[data-lightbox-page][data-current] video', - ); + const onAPage = container.querySelector('[data-lightbox-page]') != null; + const current = + container.querySelector( + '[data-lightbox-page][data-current] video', + ) ?? + (onAPage ? null : container.querySelector('video')); if (hasAutoPlay && current != null && current.paused) { // A browser can refuse playback without a user's gesture. void current.play()?.catch(() => {}); } - }, [index, hasAutoPlay]); + }, [index, hasAutoPlay, isOpen]); // The backdrop, the controls and the pages a dismissal faded, to restore // exactly those. A page faded as a neighbour may be the current one when @@ -1323,7 +1338,7 @@ export function Lightbox({ src={src} aria-label={alt} controls - autoPlay={isCurrent && hasAutoPlay} + autoPlay={isOpen && isCurrent && hasAutoPlay} preload={isCurrent ? 'auto' : 'metadata'} {...stylex.props(styles.video)} />