diff --git a/.changeset/pink-wasps-serve.md b/.changeset/pink-wasps-serve.md new file mode 100644 index 0000000000..daf9057f5b --- /dev/null +++ b/.changeset/pink-wasps-serve.md @@ -0,0 +1,8 @@ +--- +'@alfalab/core-components-gallery': minor +--- + +Добавлена поддержка скачивания видео для мобильных браузеров. +Расширены возможности управления видео в галерее. Заменен цвет для плейсхолдеров и заглушек, что улучшает видимость на темном фоне галереи. +Исправлены отступы у навигации. +При свайпе до закрытия, галерея следует за свайпом diff --git a/packages/gallery/src/Component.tsx b/packages/gallery/src/Component.tsx index c7566a6876..da22dd0e05 100644 --- a/packages/gallery/src/Component.tsx +++ b/packages/gallery/src/Component.tsx @@ -88,6 +88,7 @@ export const Gallery: FC = ({ const [mutedVideo, setMutedVideo] = useState(DEFAULT_MUTED_VIDEO); const [playingVideo, setPlayingVideo] = useState(DEFAULT_PLAYING_VIDEO); const [hideNavigation, setHideNavigation] = useState(DEFAULT_HIDE_NAVIGATION); + const [swipeY, setSwipeY] = useState(0); const isDesktop = useIsDesktop(); @@ -208,14 +209,20 @@ export const Gallery: FC = ({ }, [handleKeyDown]); useEffect(() => { - let startY: number; + let startX = 0; + let startY = 0; + let lockedDirection: 'horizontal' | 'vertical' | null = null; + const directionLockThreshold = 10; + const abortController = new AbortController(); const { signal } = abortController; document.addEventListener( 'touchstart', (e) => { + startX = e.touches[0].clientX; startY = e.touches[0].clientY; + lockedDirection = null; }, { signal }, ); @@ -223,17 +230,46 @@ export const Gallery: FC = ({ document.addEventListener( 'touchmove', (e) => { - const endY = e.changedTouches[0].clientY; - const deltaY = startY - endY; + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - startX; + const deltaY = currentY - startY; + + if (!lockedDirection) { + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + if (absX > absY && absX > directionLockThreshold) { + lockedDirection = 'horizontal'; + } else if (absY > absX && absY > directionLockThreshold) { + lockedDirection = 'vertical'; + } else { + return; + } + } - // Если свайп вниз, закрываем галерею - if (deltaY < SWIPE_THRESHOLD) { + if (lockedDirection === 'horizontal') { + return; + } + + // vertical lock + setSwipeY(deltaY); + if (deltaY < SWIPE_THRESHOLD || deltaY > -SWIPE_THRESHOLD) { onClose(); } }, { signal }, ); + document.addEventListener( + 'touchend', + () => { + lockedDirection = null; + setSwipeY(0); + }, + { signal }, + ); + return () => { abortController.abort(); }; @@ -281,6 +317,10 @@ export const Gallery: FC = ({ onUnmount={onUnmount} >
& { @@ -21,13 +24,10 @@ type Props = Omit & { download?: string | boolean; }; -export const Fullscreen: FC = ({ buttonRef, ...restProps }) => ( - +export const Fullscreen: FC = ({ buttonRef, ...restProps }) => { + const isDesktop = useIsDesktop(); + + const iconButton = ( = ({ buttonRef, ...restProps }) => ( aria-label='Открыть в полноэкранном режиме' className={styles.iconButton} /> - -); + ); + + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; export const BackArrow: FC = ({ buttonRef, ...restProps }) => ( = ({ buttonRef, className, ...restProps }) => ( /> ); -export const ExitFullscreen: FC = ({ buttonRef, ...restProps }) => ( - +export const ExitFullscreen: FC = ({ buttonRef, ...restProps }) => { + const isDesktop = useIsDesktop(); + + const iconButton = ( = ({ buttonRef, ...restProps }) => ( aria-label='Выйти из полноэкранного режима' className={styles.iconButton} /> - -); + ); + + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; -export const MuteVideo: FC = ({ buttonRef, className, ...restProps }) => ( - +export const MuteVideo: FC = ({ buttonRef, className, ...restProps }) => { + const isDesktop = useIsDesktop(); + + const iconButton = ( = ({ buttonRef, className, ...restProps }) => aria-label='Выключить звук' className={styles.iconButton} /> - -); + ); + + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; -export const UnmuteVideo: FC = ({ buttonRef, className, ...restProps }) => ( - +export const UnmuteVideo: FC = ({ buttonRef, className, ...restProps }) => { + const isDesktop = useIsDesktop(); + + const iconButton = ( = ({ buttonRef, className, ...restProps }) = aria-label='Включить звук' className={styles.iconButton} /> - -); + ); -export const Download: FC = (props) => ( - - - -); + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; + +export const Download: FC = ({ href, ...props }) => { + const isDesktop = useIsDesktop(); + + const handleMobileVideoDownload = async (e: React.MouseEvent) => { + e.preventDefault(); + + if (!href) return; + + const fileName = props.download?.toString() || 'video'; + const fileType = isVideo(href) ? 'video/*' : undefined; + + await downloadFile({ + url: href, + fileName, + fileType, + }); + }; -export const Share: FC = (props) => ( - + const iconButtonProps = { + ...props, + icon: PointerDownMIcon, + 'aria-label': 'Скачать' as const, + className: styles.iconButton, + }; + + const iconButton = + !isDesktop && isVideo(href) ? ( + + ) : ( + + ); + + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; + +export const Share: FC = (props) => { + const isDesktop = useIsDesktop(); + + const iconButton = ( - -); + ); + + return isDesktop ? ( + + {iconButton} + + ) : ( + iconButton + ); +}; export const Exit: FC = (props) => ( diff --git a/packages/gallery/src/components/header-info-block/Component.tsx b/packages/gallery/src/components/header-info-block/Component.tsx index d60a6a398d..d6d1f98b8e 100644 --- a/packages/gallery/src/components/header-info-block/Component.tsx +++ b/packages/gallery/src/components/header-info-block/Component.tsx @@ -18,16 +18,16 @@ export const HeaderInfoBlock: FC = ({ filename, descriptio
{head} - + {tail}
diff --git a/packages/gallery/src/components/header/index.module.css b/packages/gallery/src/components/header/index.module.css index f92a445604..35233454b4 100644 --- a/packages/gallery/src/components/header/index.module.css +++ b/packages/gallery/src/components/header/index.module.css @@ -5,6 +5,6 @@ justify-content: space-between; flex-shrink: 0; width: 100%; - padding: var(--gap-16) var(--gap-32); + padding: var(--gap-16) var(--gap-24) var(--gap-16) var(--gap-32); box-sizing: border-box; } diff --git a/packages/gallery/src/components/image-preview/Component.tsx b/packages/gallery/src/components/image-preview/Component.tsx index ca9036568b..4e18b780e5 100644 --- a/packages/gallery/src/components/image-preview/Component.tsx +++ b/packages/gallery/src/components/image-preview/Component.tsx @@ -151,7 +151,7 @@ export const ImagePreview: FC = ({ image, active = false, index, onSelect return (
diff --git a/packages/gallery/src/components/image-preview/index.module.css b/packages/gallery/src/components/image-preview/index.module.css index 778d9c0a89..4f845b1e56 100644 --- a/packages/gallery/src/components/image-preview/index.module.css +++ b/packages/gallery/src/components/image-preview/index.module.css @@ -59,7 +59,7 @@ background-color: var(--color-static-neutral-100-inverted); & .active { - background-color: var(--color-static-neutral-300-inverted); + background-color: var(--color-static-neutral-translucent-100-inverted); } } @@ -67,7 +67,7 @@ display: flex; justify-content: center; align-items: center; - background-color: var(--color-static-neutral-300-inverted); + background-color: var(--color-static-neutral-translucent-100-inverted); opacity: 0.3; &.active { diff --git a/packages/gallery/src/components/image-viewer/component.tsx b/packages/gallery/src/components/image-viewer/component.tsx index 4b3532bde0..23c55b749e 100644 --- a/packages/gallery/src/components/image-viewer/component.tsx +++ b/packages/gallery/src/components/image-viewer/component.tsx @@ -135,6 +135,7 @@ export const ImageViewer: FC = () => { src={currentImage?.src} alt={currentImage ? getImageAlt(currentImage, currentSlideIndex) : ''} className={styles.fullScreenImage} + data-content-area='true' /> )} diff --git a/packages/gallery/src/components/image-viewer/hooks.ts b/packages/gallery/src/components/image-viewer/hooks.ts index dded28d23e..bb6395bd93 100644 --- a/packages/gallery/src/components/image-viewer/hooks.ts +++ b/packages/gallery/src/components/image-viewer/hooks.ts @@ -42,9 +42,10 @@ export const useHandleImageViewer = () => { const isPlaceholder = Boolean(eventTarget.closest(`.${styles.placeholder}`)); - const isImg = eventTarget.tagName === 'IMG'; + const isContentArea = Boolean(eventTarget.closest('[data-content-area]')); - if (!isImg && !isPlaceholder && !isArrow && !isMobile) { + // Закрываем галерею только при клике вне элементов контента и только на desktop + if (!isPlaceholder && !isArrow && !isContentArea && !isMobile) { onClose(); } }, diff --git a/packages/gallery/src/components/image-viewer/index.module.css b/packages/gallery/src/components/image-viewer/index.module.css index b57455f23e..d5f5812bd1 100644 --- a/packages/gallery/src/components/image-viewer/index.module.css +++ b/packages/gallery/src/components/image-viewer/index.module.css @@ -32,7 +32,7 @@ height: 100%; max-height: calc(100vh - 80px); - padding: var(--gap-32); + padding: var(--gap-0) var(--gap-32) var(--gap-32); box-sizing: border-box; &.mobile { @@ -129,7 +129,7 @@ width: 100%; height: 100%; border-radius: var(--border-radius-8); - background-color: var(--color-static-neutral-300-inverted); + background-color: var(--color-static-neutral-translucent-100-inverted); } .brokenImgWrapper { diff --git a/packages/gallery/src/components/image-viewer/single.tsx b/packages/gallery/src/components/image-viewer/single.tsx index 236a5f07f7..64fba357c2 100644 --- a/packages/gallery/src/components/image-viewer/single.tsx +++ b/packages/gallery/src/components/image-viewer/single.tsx @@ -39,6 +39,7 @@ export const Single: FC = () => { src={currentImage?.src} alt={currentImage ? getImageAlt(currentImage, currentSlideIndex) : ''} className={styles.fullScreenImage} + data-content-area='true' /> ) : (
= ({ children, broken, loading, isVideoVie ); return ( -
+
{broken ?
{content}
: content}
@@ -91,7 +91,12 @@ export const Slide: FC = ({ if (isVideo(image.src)) { return ( - + ); @@ -114,6 +119,7 @@ export const Slide: FC = ({ style={{ maxHeight: `${containerHeight}px`, }} + data-content-area='true' data-test-id={slideVisible ? TestIds.ACTIVE_IMAGE : undefined} /> diff --git a/packages/gallery/src/components/image-viewer/video/index.module.css b/packages/gallery/src/components/image-viewer/video/index.module.css index d6800b1ba7..8c07c39c1b 100644 --- a/packages/gallery/src/components/image-viewer/video/index.module.css +++ b/packages/gallery/src/components/image-viewer/video/index.module.css @@ -6,6 +6,16 @@ height: 100%; width: auto; position: relative; + min-width: 300px; + min-height: 200px; +} + +.videoWrapper.loading { + width: 100%; + aspect-ratio: 16/9; + background-color: var(--color-static-neutral-translucent-100-inverted); + border-radius: var(--border-radius-24); + align-items: center; } .video { diff --git a/packages/gallery/src/components/image-viewer/video/index.tsx b/packages/gallery/src/components/image-viewer/video/index.tsx index 8c9e3e8563..b536ee68c2 100644 --- a/packages/gallery/src/components/image-viewer/video/index.tsx +++ b/packages/gallery/src/components/image-viewer/video/index.tsx @@ -5,6 +5,7 @@ import React, { useContext, useEffect, useRef, + useState, } from 'react'; import cn from 'classnames'; import Hls from 'hls.js'; @@ -26,8 +27,11 @@ type Props = { }; export const Video = ({ url, index, className, isActive }: Props) => { + const [videoLoaded, setVideoLoaded] = useState(false); const playerRef = useRef(null); const timer = useRef>(); + const abortController = useRef(new AbortController()); + const [videoError, setVideoError] = useState(false); const { setImageMeta, @@ -49,6 +53,47 @@ export const Video = ({ url, index, className, isActive }: Props) => { /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [index]); + useEffect(() => () => abortController.current.abort(), []); + + useEffect(() => { + const video = playerRef.current; + + if (!video) { + return; + } + + const { signal } = abortController.current; + + const handleCanPlay = () => { + setVideoLoaded(true); + setVideoError(false); + setImageMeta( + { + player: playerRef, + width: video.videoWidth || 1920, + height: video.videoHeight || 1080, + loaded: true, + }, + index, + ); + }; + + const handleError = () => { + setVideoError(true); + setVideoLoaded(false); + setImageMeta({ player: { current: null }, broken: true, loaded: true }, index); + }; + + const handleLoadStart = () => { + setVideoLoaded(false); + setVideoError(false); + }; + + video.addEventListener('canplay', handleCanPlay, { signal }); + video.addEventListener('error', handleError, { signal }); + video.addEventListener('loadstart', handleLoadStart, { signal }); + }, [setImageMeta, index]); + useEffect(() => { const hls = new Hls(); @@ -60,6 +105,7 @@ export const Video = ({ url, index, className, isActive }: Props) => { hls.recoverMediaError(); break; case Hls.ErrorTypes.NETWORK_ERROR: + setVideoError(true); setImageMeta({ player: { current: null }, broken: true }, index); break; default: @@ -102,8 +148,12 @@ export const Video = ({ url, index, className, isActive }: Props) => { }, [isActive, playingVideo]); useEffect(() => { + const { signal } = abortController.current; + const handleSpacePress = (e: KeyboardEvent) => { if ((e.key === ' ' || e.code === 'Space') && isActive) { + e.preventDefault(); + e.stopPropagation(); if (playingVideo) { setPlayingVideo(false); } else { @@ -112,11 +162,7 @@ export const Video = ({ url, index, className, isActive }: Props) => { } }; - document.addEventListener('keyup', handleSpacePress); - - return () => { - document.removeEventListener('keyup', handleSpacePress); - }; + document.addEventListener('keydown', handleSpacePress, { signal }); }, [isActive, playingVideo, setPlayingVideo]); const handleWrapperClick = (e: MouseEvent) => { @@ -133,11 +179,9 @@ export const Video = ({ url, index, className, isActive }: Props) => { setHideNavigation(true); }, 3000); } - - return; + } else { + setPlayingVideo(!playingVideo); } - - setPlayingVideo(!playingVideo); }; const handleBottomButtonClick = useCallback( @@ -178,8 +222,14 @@ export const Video = ({ url, index, className, isActive }: Props) => { }; return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
+
- {isDesktop && !playingVideo && ( + {isDesktop && !playingVideo && videoLoaded && (
@@ -200,7 +250,7 @@ export const Video = ({ url, index, className, isActive }: Props) => {
)} {isDesktop && } - {isDesktop && image?.bottomButton && ( + {isDesktop && image?.bottomButton && videoLoaded && ( {}, - timeout: 2, + timeout, }, }, { diff --git a/packages/gallery/src/types.ts b/packages/gallery/src/types.ts index 194dc1ec7c..4ebf0f400c 100644 --- a/packages/gallery/src/types.ts +++ b/packages/gallery/src/types.ts @@ -43,10 +43,12 @@ export type ImageMeta = height: number; broken?: boolean; player?: never; + loaded?: boolean; } | { - width?: never; - height?: never; + width?: number; + height?: number; broken?: boolean; player: RefObject; + loaded?: boolean; }; diff --git a/packages/gallery/src/utils/index.ts b/packages/gallery/src/utils/index.ts index 5081f80f62..c180cf6ae0 100644 --- a/packages/gallery/src/utils/index.ts +++ b/packages/gallery/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './split-filename'; export * from './utils'; export * from './constants'; +export * from './modern-download'; diff --git a/packages/gallery/src/utils/modern-download.ts b/packages/gallery/src/utils/modern-download.ts new file mode 100644 index 0000000000..064a64b759 --- /dev/null +++ b/packages/gallery/src/utils/modern-download.ts @@ -0,0 +1,92 @@ +const downloadWithBlob = (blob: Blob, fileName: string): void => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 100); +}; + +const downloadWithLink = (url: string, fileName: string): void => { + const link = document.createElement('a'); + + link.href = url; + link.download = fileName; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +export interface DownloadOptions { + url: string; + fileName?: string; + fileType?: string; +} + +export const downloadFile = async ({ + url, + fileName = 'download', + fileType, +}: DownloadOptions): Promise => { + if ('share' in navigator && navigator?.canShare) { + const response = await fetch(url); + const blob = await response.blob(); + + const file = new File([blob], fileName, { type: blob.type || fileType }); + + if (navigator?.canShare?.({ files: [file] })) { + await navigator.share({ + files: [file], + title: `Скачать ${fileName}`, + }); + + return; + } + } + + if ('WritableStream' in window && 'ReadableStream' in window) { + const response = await fetch(url); + + if (!response.ok) throw new Error('Network error'); + + const contentLength = response.headers.get('content-length'); + const isLargeFile = contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024; // 10MB + + if (isLargeFile && response.body) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + + const readAllChunks = async (): Promise => { + const { done, value } = await reader.read(); + + if (!done) { + chunks.push(value); + + return readAllChunks(); + } + + return Promise.resolve(); + }; + + await readAllChunks(); + const blob = new Blob(chunks); + + downloadWithBlob(blob, fileName); + + return; + } + + const blob = await response.blob(); + + downloadWithBlob(blob, fileName); + + return; + } + + downloadWithLink(url, fileName); +};