@@ -1,41 +1,86 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Swiper as SwiperCore } from 'swiper' export const useLibrarySwiper = (onSlideFalse: ((...args: any) => any) | undefined, reverse: boolean) => { const [swiper, setSwiper] = useState(null) + const swiperRef = useRef(null) + const onSlideFalseRef = useRef(onSlideFalse) + const reverseRef = useRef(reverse) + swiperRef.current = swiper + onSlideFalseRef.current = onSlideFalse + reverseRef.current = reverse - const keydown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - slideNext() - } - if (e.key === 'ArrowLeft') { - slidePrev() + const requestMoreIfNeeded = (direction: 'prev' | 'next') => { + const shouldPaginate = + (direction === 'prev' && !reverseRef.current) || (direction === 'next' && reverseRef.current) + + if (shouldPaginate && onSlideFalseRef.current) { + void onSlideFalseRef.current() } } - const slidePrev = function () { - const result = swiper?.slidePrev() + const slidePrev = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && !reverse) { - onSlideFalse() + const atBeginning = instance.isBeginning || instance.activeIndex <= 0 + if (atBeginning) { + requestMoreIfNeeded('prev') + return + } + + const prevIndex = instance.activeIndex + instance.slidePrev() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('prev') } } - const slideNext = function () { - const result = swiper?.slideNext() + const slideNext = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && reverse) { - onSlideFalse() + const lastIndex = Math.max(instance.slides.length - 1, 0) + const atEnd = instance.isEnd || instance.activeIndex >= lastIndex + if (atEnd) { + requestMoreIfNeeded('next') + return + } + + const prevIndex = instance.activeIndex + instance.slideNext() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('next') } } + const slidePrevRef = useRef(slidePrev) + const slideNextRef = useRef(slideNext) + slidePrevRef.current = slidePrev + slideNextRef.current = slideNext + useEffect(() => { + const keydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + slideNextRef.current() + } + if (e.key === 'ArrowLeft') { + slidePrevRef.current() + } + } + document.addEventListener('keydown', keydown, true) return () => { document.removeEventListener('keydown', keydown, true) } - }, [swiper]) + }, []) return { swiper, @@ -69,9 +69,14 @@ export default function FullScreenModal({ const delta = len - prevLen const id = window.setTimeout(() => { + swiper.update() + if (reverse) { - swiper.update() + // Desktop: older items are appended at the end — jump to the first newly loaded slide + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) } else { + // Mobile: older items are prepended — keep the same visual position const nextIndex = swiper.activeIndex + delta const clamped = Math.max(0, Math.min(nextIndex, len - 1)) swiper.slideTo(clamped, 0) @@ -0,0 +1,116 @@ +.overlay { + position: fixed; + z-index: 1201; + inset: 0; + width: 100%; + height: 100%; + display: none; +} + +.overlayVisible { + display: block; +} + +.backdrop { + position: absolute; + z-index: 101; + width: 100%; + height: 100%; + background-color: #000000; + opacity: 0.9; +} + +.actions { + position: absolute; + z-index: 105; + cursor: pointer; + right: 25px; + top: 25px; + display: flex; + align-items: center; + gap: 15px; +} + +.videoBlock { + position: absolute; + padding: 0 20px; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 105; + margin: auto; + width: fit-content; + height: fit-content; +} + +.arrow { + position: absolute; + z-index: 1205; + cursor: pointer; + top: 50%; + width: 50px; + height: 50px; + background: transparent; + border: none; + outline: none; + + svg { + fill: white; + width: 50px; + height: 50px; + } + + &_left { + transform: translateY(-50%) rotate(90deg); + left: -40px; + + @media screen and (max-width: 768px) { + left: -3px; + } + } + + &_right { + transform: translateY(-50%) rotate(-90deg); + right: -50px; + + @media screen and (max-width: 768px) { + right: -10px; + } + } +} + +.swiper { + position: relative; + width: 100%; + max-width: 80vw; + max-height: 1000px; + height: 80vh; +} + +.slide { + width: 80vw !important; + display: flex !important; + align-items: center; + justify-content: center; + height: 100% !important; + max-height: unset; +} + +.slideInner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.video { + width: 100%; + height: 100%; + max-width: 80vw; + max-height: 80vh; + object-fit: contain; + background: #000; +} @@ -1,199 +1,298 @@ -import React, { useEffect, useRef } from 'react'; -import { Close } from '@mui/icons-material'; -import { - Box, - Dialog, - DialogContent, - IconButton, -} from '@mui/material'; +import { useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDropDown } from '@mui/icons-material' +import { Swiper, SwiperSlide } from 'swiper/react' +import 'swiper/css' + +import { Message } from '#/entities/message' +import { useLibrarySwiper } from '#/features/image-modal/model/use-swiper' +import { c } from '#/shared' +import { useImageIcons } from '#/widgets/messages' + +import styles from './video-player-modal.module.scss' export interface VideoPlayerModalProps { - open: boolean; - onClose: () => void; - videoUrl: string; - title?: string; - autoPlay?: boolean; - } + open: boolean + onClose: () => void + videos: Message[] + current: string | null + onSlideFalse?: () => void | Promise + reverse?: boolean + autoPlay?: boolean + /** @deprecated use videos + current */ + videoUrl?: string + /** @deprecated unused */ + title?: string +} + +function VideoSlide({ + file, + isActive, + autoPlay, +}: { + file: string + isActive: boolean + autoPlay: boolean +}) { + const videoRef = useRef(null) + + useEffect(() => { + const video = videoRef.current + if (!video) { + return + } + + if (isActive && autoPlay) { + video.currentTime = 0 + void video.play().catch(() => undefined) + return + } + + video.pause() + }, [isActive, autoPlay, file]) + + return ( +
e.stopPropagation()}> + +
+ ) +} const VideoPlayerModal: React.FC = ({ - open, - onClose, - videoUrl, - title = 'Видео', - autoPlay = true, + open, + onClose, + videos, + current, + onSlideFalse, + reverse = false, + autoPlay = true, + videoUrl, }) => { - const videoRef = useRef(null); - - useEffect(() => { - if (videoRef.current && open) { - const video = videoRef.current; - - // Принудительно останавливаем автовоспроизведение при паузе - const handlePause = () => { - if (!video.paused) { - video.pause(); - } - }; - - const handlePlay = () => { - // Убеждаемся, что видео действительно воспроизводится - if (video.paused) { - video.play().catch(() => { - // Игнорируем ошибки воспроизведения - }); - } - }; - - // Дополнительная обработка для предотвращения автоматического воспроизведения после паузы - const handleTimeUpdate = () => { - // Если видео приостановлено, но время продолжает обновляться, принудительно останавливаем - if (video.paused && video.currentTime !== video.currentTime) { - video.pause(); - } - }; - - // Обработка изменения состояния загрузки - const handleLoadStart = () => { - video.pause(); - }; - - // Обработка видимости страницы (для мобильных устройств) - const handleVisibilityChange = () => { - if (document.hidden && !video.paused) { - video.pause(); - } - }; - - // Обработка потери фокуса (для мобильных устройств) - const handleBlur = () => { - if (!video.paused) { - video.pause(); - } - }; - - video.addEventListener('pause', handlePause); - video.addEventListener('play', handlePlay); - video.addEventListener('timeupdate', handleTimeUpdate); - video.addEventListener('loadstart', handleLoadStart); - document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('blur', handleBlur); - - // Настройка для мобильных устройств - video.setAttribute('playsinline', 'true'); - video.setAttribute('webkit-playsinline', 'true'); - - // Устанавливаем preload для лучшего контроля - video.preload = 'metadata'; - - return () => { - video.removeEventListener('pause', handlePause); - video.removeEventListener('play', handlePlay); - video.removeEventListener('timeupdate', handleTimeUpdate); - video.removeEventListener('loadstart', handleLoadStart); - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('blur', handleBlur); - }; - } - }, [open, videoUrl]); - - // Приостанавливаем видео при закрытии модального окна - const handleClose = () => { - if (videoRef.current && !videoRef.current.paused) { - videoRef.current.pause(); - } - onClose(); - }; - - return ( - - {/* Контент с видео */} - - {/* Кнопка закрытия */} - - - - - - - - - - Ваш браузер не поддерживает видео. - - - - - ); -}; - -export default VideoPlayerModal; \ No newline at end of file + const libraryVideos = useMemo(() => { + if (videos?.length) { + return videos + } + + if (videoUrl) { + return [ + { + uid: 'legacy-video', + file: videoUrl, + content: '', + created_at: '', + elapsed_time: '', + from_model: true, + info: null, + is_favourite: false, + is_sent: true, + model: '', + } satisfies Message, + ] + } + + return [] + }, [videos, videoUrl]) + + const currentIndex = useMemo(() => { + const index = libraryVideos.findIndex((item) => item.file === current || item.file === videoUrl) + return index >= 0 ? index : 0 + }, [libraryVideos, current, videoUrl]) + + const prevVideosLengthRef = useRef(0) + const [activeIndex, setActiveIndex] = useState(currentIndex) + + const { swiper, setSwiper, slideNext, slidePrev } = useLibrarySwiper(onSlideFalse, reverse) + const { downloadFile } = useImageIcons() + + useEffect(() => { + if (open) { + setActiveIndex(currentIndex) + } + }, [open, currentIndex]) + + useEffect(() => { + if (!open) { + prevVideosLengthRef.current = 0 + return + } + if (!swiper) { + return + } + + const len = libraryVideos.length + const prevLen = prevVideosLengthRef.current + + if (prevLen === 0) { + prevVideosLengthRef.current = len + return + } + + if (len === prevLen) { + return + } + + if (len < prevLen) { + prevVideosLengthRef.current = len + return + } + + const delta = len - prevLen + + const id = window.setTimeout(() => { + swiper.update() + + if (reverse) { + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) + setActiveIndex(firstNewIndex) + } else { + const nextIndex = swiper.activeIndex + delta + const clamped = Math.max(0, Math.min(nextIndex, len - 1)) + swiper.slideTo(clamped, 0) + setActiveIndex(clamped) + } + + prevVideosLengthRef.current = len + }, 0) + + return () => clearTimeout(id) + }, [open, libraryVideos.length, reverse, swiper]) + + useEffect(() => { + if (!open) { + return + } + + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + window.addEventListener('keydown', onEsc) + return () => window.removeEventListener('keydown', onEsc) + }, [open, onClose]) + + return ( +
+
+
+
+ { + e.stopPropagation() + if (!swiper) return + const item = libraryVideos[swiper.activeIndex] + if (!item?.file) return + downloadFile(item.file, item.content) + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
+ { + e.stopPropagation() + if (!swiper) return + const item = libraryVideos[swiper.activeIndex] + if (!item?.file) return + window.open(item.file, '_blank') + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
+ + + +
+
+ +
+ + + {open && libraryVideos.length > 0 && ( + setSwiper(instance)} + onSlideChange={(instance) => { + setActiveIndex(instance.activeIndex) + }} + > + {libraryVideos.map((video, index) => ( + + + + ))} + + )} + + +
+
+ ) +} + +export default VideoPlayerModal @@ -1,41 +1,86 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Swiper as SwiperCore } from 'swiper' export const useLibrarySwiper = (onSlideFalse: ((...args: any) => any) | undefined, reverse: boolean) => { const [swiper, setSwiper] = useState(null) + const swiperRef = useRef(null) + const onSlideFalseRef = useRef(onSlideFalse) + const reverseRef = useRef(reverse) + swiperRef.current = swiper + onSlideFalseRef.current = onSlideFalse + reverseRef.current = reverse - const keydown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - slideNext() - } - if (e.key === 'ArrowLeft') { - slidePrev() + const requestMoreIfNeeded = (direction: 'prev' | 'next') => { + const shouldPaginate = + (direction === 'prev' && !reverseRef.current) || (direction === 'next' && reverseRef.current) + + if (shouldPaginate && onSlideFalseRef.current) { + void onSlideFalseRef.current() } } - const slidePrev = function () { - const result = swiper?.slidePrev() + const slidePrev = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && !reverse) { - onSlideFalse() + const atBeginning = instance.isBeginning || instance.activeIndex <= 0 + if (atBeginning) { + requestMoreIfNeeded('prev') + return + } + + const prevIndex = instance.activeIndex + instance.slidePrev() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('prev') } } - const slideNext = function () { - const result = swiper?.slideNext() + const slideNext = () => { + const instance = swiperRef.current + if (!instance) { + return + } - if (!result && onSlideFalse && reverse) { - onSlideFalse() + const lastIndex = Math.max(instance.slides.length - 1, 0) + const atEnd = instance.isEnd || instance.activeIndex >= lastIndex + if (atEnd) { + requestMoreIfNeeded('next') + return + } + + const prevIndex = instance.activeIndex + instance.slideNext() + + if (instance.activeIndex === prevIndex) { + requestMoreIfNeeded('next') } } + const slidePrevRef = useRef(slidePrev) + const slideNextRef = useRef(slideNext) + slidePrevRef.current = slidePrev + slideNextRef.current = slideNext + useEffect(() => { + const keydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + slideNextRef.current() + } + if (e.key === 'ArrowLeft') { + slidePrevRef.current() + } + } + document.addEventListener('keydown', keydown, true) return () => { document.removeEventListener('keydown', keydown, true) } - }, [swiper]) + }, []) return { swiper, @@ -69,9 +69,14 @@ export default function FullScreenModal({ const delta = len - prevLen const id = window.setTimeout(() => { + swiper.update() + if (reverse) { - swiper.update() + // Desktop: older items are appended at the end — jump to the first newly loaded slide + const firstNewIndex = Math.min(prevLen, len - 1) + swiper.slideTo(firstNewIndex, 0) } else { + // Mobile: older items are prepended — keep the same visual position const nextIndex = swiper.activeIndex + delta const clamped = Math.max(0, Math.min(nextIndex, len - 1)) swiper.slideTo(clamped, 0) @@ -236,7 +236,11 @@ export const ImageMessagesList: React.FC = memo( getMessagesPagination && getMessagesPagination()} + onSlideFalse={() => { + if (hasMore && getMessagesPagination) { + void getMessagesPagination() + } + }} setModal={setModal} reverse={device === 'desktop'} current={chosenImage} @@ -1,13 +1,14 @@ -import React, { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' +import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Box, CircularProgress, Skeleton, Typography } from '@mui/material' import { ImageIcons } from './image-icons' import styles from './image-messages-list.module.scss' -import { VideoMessage } from '#/entities/message' +import { Message, VideoMessage } from '#/entities/message' import VideoPlayerModal from '#/features/video-player/video-player-modal' -import { TooltipCustom } from '#/shared' +import { ClientOnly, TooltipCustom } from '#/shared' interface MessagesList { device: 'mobile' | 'desktop' @@ -32,8 +33,18 @@ export const VideoMessagesList: React.FC = memo( const [loadedVideos, setLoadedVideos] = useState>(new Set()) const videoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({}) const [isModalOpen, setIsModalOpen] = useState(false) - const [selectedVideoUrl, setSelectedVideoUrl] = useState('') - const [selectedVideoTitle, setSelectedVideoTitle] = useState('') + const [selectedVideoUrl, setSelectedVideoUrl] = useState(null) + + const libraryVideos = useMemo[]>(() => { + if (!videos?.length) { + return [] + } + + return videos.filter( + (message): message is VideoMessage => + typeof message.file === 'string' && message.file.trim() !== '' + ) as Message[] + }, [videos]) const paginationSentinelRef = useRef(null) const intersectionObserverRef = useRef(null) @@ -121,16 +132,14 @@ export const VideoMessagesList: React.FC = memo( setLoadedVideos((prev) => new Set([...prev, uid])) } - const handleVideoClick = (uid: string, videoUrl: string, content: string) => { + const handleVideoClick = (_uid: string, videoUrl: string) => { setSelectedVideoUrl(videoUrl) - setSelectedVideoTitle(content || 'Видео') setIsModalOpen(true) } const handleCloseModal = () => { setIsModalOpen(false) - setSelectedVideoUrl('') - setSelectedVideoTitle('') + setSelectedVideoUrl(null) } useEffect(() => { @@ -239,13 +248,24 @@ export const VideoMessagesList: React.FC = memo( return ( <> - + + {createPortal( + { + if (hasMore && getMessagesPagination) { + void getMessagesPagination() + } + }} + />, + document.getElementById('modal-container')! + )} + {device === 'mobile' && paginationLoading && ( @@ -311,7 +331,7 @@ export const VideoMessagesList: React.FC = memo( videoRefs.current[message.uid] = el }} className={styles.background_video} - onClick={() => handleVideoClick(message.uid, fileUrl, message.content)} + onClick={() => handleVideoClick(message.uid, fileUrl)} onLoadedData={() => handleVideoLoad(message.uid)} preload='auto' muted