forked from 77media/video-flow
瀑布流布局
This commit is contained in:
parent
272cc8ba41
commit
17d57092be
@ -69,13 +69,14 @@ interface ListMovieProjectsParams {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MovieProject {
|
export interface MovieProject {
|
||||||
project_id: string;
|
project_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
step: string;
|
step: string;
|
||||||
final_video_url: string;
|
final_video_url: string;
|
||||||
final_simple_video_url: string;
|
final_simple_video_url: string;
|
||||||
|
video_urls: string;
|
||||||
last_message: string;
|
last_message: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@ -5,13 +5,14 @@ import { Loader2, Download } from 'lucide-react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import './style/create-to-video2.css';
|
import './style/create-to-video2.css';
|
||||||
|
|
||||||
import { getScriptEpisodeListNew } from "@/api/script_episode";
|
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
|
||||||
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||||
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
import cover_image1 from '@/public/assets/cover_image3.jpg';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Tooltip, Button } from 'antd';
|
import { Tooltip, Button } from 'antd';
|
||||||
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
||||||
import LazyLoad from "react-lazyload";
|
import Masonry from 'react-masonry-css';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -19,35 +20,75 @@ import LazyLoad from "react-lazyload";
|
|||||||
|
|
||||||
export default function CreateToVideo2() {
|
export default function CreateToVideo2() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [episodeList, setEpisodeList] = useState<any[]>([]);
|
const [episodeList, setEpisodeList] = useState<MovieProject[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [perPage] = useState(12);
|
const [perPage] = useState(28);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [isPreloading, setIsPreloading] = useState(false);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [userId, setUserId] = useState<number>(0);
|
const [userId, setUserId] = useState<number>(0);
|
||||||
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
||||||
|
const masonryRef = useRef<any>(null);
|
||||||
|
interface PreloadedData {
|
||||||
|
page: number;
|
||||||
|
data: {
|
||||||
|
code: number;
|
||||||
|
data: {
|
||||||
|
movie_projects: MovieProject[];
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const preloadedDataRef = useRef<PreloadedData | null>(null);
|
||||||
|
|
||||||
// 添加一个 ref 来跟踪当前正在加载的页码
|
// 添加一个 ref 来跟踪当前正在加载的页码
|
||||||
const loadingPageRef = useRef<number | null>(null);
|
const loadingPageRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 在客户端挂载后读取localStorage
|
// 在客户端挂载后读取localStorage
|
||||||
// 监听滚动事件,实现无限加载
|
// 预加载下一页数据
|
||||||
// 修改滚动处理函数,添加节流
|
const preloadNextPage = async (userId: number, page: number) => {
|
||||||
|
if (isPreloading || !hasMore || page > totalPages) return;
|
||||||
|
|
||||||
|
setIsPreloading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchEpisodeData(userId, page);
|
||||||
|
if (response.code === 0) {
|
||||||
|
preloadedDataRef.current = {
|
||||||
|
page,
|
||||||
|
data: response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preload next page:', error);
|
||||||
|
} finally {
|
||||||
|
setIsPreloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听滚动事件,实现无限加载和预加载
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
|
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||||
if (scrollHeight - scrollTop - clientHeight < 100) {
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
// 直接使用 currentPage,不再使用 setCurrentPage 的回调
|
|
||||||
|
// 在滚动到 30% 时预加载下一页
|
||||||
|
// if (scrollPercentage > 0.30 && !isPreloading && currentPage < totalPages) {
|
||||||
|
// preloadNextPage(userId, currentPage + 1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 在滚动到 70% 时加载下一页
|
||||||
|
if (scrollPercentage > 0.7) {
|
||||||
const nextPage = currentPage + 1;
|
const nextPage = currentPage + 1;
|
||||||
if (nextPage <= totalPages) {
|
if (nextPage <= totalPages) {
|
||||||
getEpisodeList(userId, nextPage, true);
|
getEpisodeList(userId, nextPage, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage]);
|
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage, isPreloading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -67,6 +108,16 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
// 获取剧集列表数据
|
||||||
|
const fetchEpisodeData = async (userId: number, page: number) => {
|
||||||
|
const params = {
|
||||||
|
user_id: String(userId),
|
||||||
|
page,
|
||||||
|
per_page: perPage
|
||||||
|
};
|
||||||
|
return await getScriptEpisodeListNew(params);
|
||||||
|
};
|
||||||
|
|
||||||
// 修改获取剧集列表函数
|
// 修改获取剧集列表函数
|
||||||
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
|
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
|
||||||
// 检查是否正在加载该页
|
// 检查是否正在加载该页
|
||||||
@ -83,13 +134,15 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
let episodeListResponse;
|
||||||
user_id: String(userId),
|
|
||||||
page,
|
// 如果有预加载的数据且页码匹配,直接使用
|
||||||
per_page: perPage
|
if (preloadedDataRef.current && preloadedDataRef.current.page === page) {
|
||||||
};
|
episodeListResponse = preloadedDataRef.current.data;
|
||||||
|
preloadedDataRef.current = null;
|
||||||
const episodeListResponse = await getScriptEpisodeListNew(params);
|
} else {
|
||||||
|
episodeListResponse = await fetchEpisodeData(userId, page);
|
||||||
|
}
|
||||||
|
|
||||||
if (episodeListResponse.code === 0) {
|
if (episodeListResponse.code === 0) {
|
||||||
const { movie_projects, total_pages } = episodeListResponse.data;
|
const { movie_projects, total_pages } = episodeListResponse.data;
|
||||||
@ -109,6 +162,11 @@ export default function CreateToVideo2() {
|
|||||||
setTotalPages(total_pages);
|
setTotalPages(total_pages);
|
||||||
setHasMore(page < total_pages);
|
setHasMore(page < total_pages);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
|
|
||||||
|
// 预加载下一页数据
|
||||||
|
// if (page < total_pages && !isPreloading) {
|
||||||
|
// preloadNextPage(userId, page + 1);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -188,38 +246,72 @@ export default function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderProjectCard = (project: any) => {
|
// 监听窗口大小变化,触发 Masonry 重排
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = debounce(() => {
|
||||||
|
if (masonryRef.current?.recomputeCellPositions) {
|
||||||
|
masonryRef.current.recomputeCellPositions();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
handleResize.cancel();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderProjectCard = (project: MovieProject): JSX.Element => {
|
||||||
|
// 根据 aspect_ratio 计算纵横比
|
||||||
|
const getAspectRatio = () => {
|
||||||
|
switch (project.aspect_ratio) {
|
||||||
|
case "VIDEO_ASPECT_RATIO_LANDSCAPE":
|
||||||
|
return 16 / 9; // 横屏 16:9
|
||||||
|
case "VIDEO_ASPECT_RATIO_PORTRAIT":
|
||||||
|
return 9 / 16; // 竖屏 9:16
|
||||||
|
default:
|
||||||
|
return 16 / 9; // 默认横屏
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyLoad key={project.project_id} once>
|
|
||||||
<div
|
<div
|
||||||
key={project.project_id}
|
key={project.project_id}
|
||||||
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
className="relative group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
|
||||||
onMouseEnter={() => handleMouseEnter(project.project_id)}
|
onMouseEnter={() => handleMouseEnter(project.project_id)}
|
||||||
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
onMouseLeave={() => handleMouseLeave(project.project_id)}
|
||||||
data-alt="project-card"
|
data-alt="project-card"
|
||||||
>
|
>
|
||||||
{/* 视频/图片区域 */}
|
{/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
|
||||||
<div className="relative w-full pb-[56.25%]" onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}>
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{
|
||||||
|
aspectRatio: aspectRatio
|
||||||
|
}}
|
||||||
|
onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}
|
||||||
|
>
|
||||||
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
|
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => setVideoRef(project.project_id, el)}
|
ref={(el) => setVideoRef(project.project_id, el)}
|
||||||
src={project.final_video_url || project.final_simple_video_url || project.video_urls}
|
src={project.final_video_url || project.final_simple_video_url || project.video_urls || ''}
|
||||||
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500"
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="auto"
|
||||||
poster={
|
poster={
|
||||||
getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls)
|
getFirstFrame(project.final_video_url || project.final_simple_video_url || project.video_urls || '', 300)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full bg-contain bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${cover_image1.src})`,
|
backgroundImage: `url(${cover_image1.src})`,
|
||||||
}}
|
}}
|
||||||
|
data-alt="cover-image"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -247,7 +339,7 @@ export default function CreateToVideo2() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部信息 */}
|
{/* 底部信息 */}
|
||||||
<div className="p-4 group">
|
<div className="p-2 group absolute bottom-0 left-0 right-0 bg-black/10 backdrop-blur-lg rounded-b-lg hidden group-hover:block">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-medium text-white line-clamp-1">
|
<h2 className="text-sm font-medium text-white line-clamp-1">
|
||||||
@ -265,7 +357,7 @@ export default function CreateToVideo2() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LazyLoad>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -282,9 +374,20 @@ export default function CreateToVideo2() {
|
|||||||
{episodeList.length > 0 && (
|
{episodeList.length > 0 && (
|
||||||
/* 优化的剧集网格 */
|
/* 优化的剧集网格 */
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
{(() => {
|
||||||
{episodeList.map(renderProjectCard)}
|
const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
|
||||||
</div>
|
return (
|
||||||
|
<Masonry
|
||||||
|
ref={masonryRef}
|
||||||
|
breakpointCols={masonryBreakpoints}
|
||||||
|
className="flex -ml-2"
|
||||||
|
columnClassName="pl-2 space-y-2"
|
||||||
|
data-alt="masonry-grid"
|
||||||
|
>
|
||||||
|
{episodeList.map(renderProjectCard)}
|
||||||
|
</Masonry>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 加载更多指示器 */}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -93,6 +93,7 @@
|
|||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
"react-lazyload": "^3.2.1",
|
"react-lazyload": "^3.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
@ -17064,6 +17065,15 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-masonry-css": {
|
||||||
|
"version": "1.0.16",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
|
||||||
|
"integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
|||||||
@ -98,6 +98,7 @@
|
|||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
"react-lazyload": "^3.2.1",
|
"react-lazyload": "^3.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-masonry-css": "^1.0.16",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
|
|||||||
@ -110,10 +110,10 @@ export const downloadAllVideos = async (urls: string[]) => {
|
|||||||
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
* 发现 链接 包含 ‘aliyuncs.com’ 是阿里云地址
|
||||||
* @param url 视频URL
|
* @param url 视频URL
|
||||||
*/
|
*/
|
||||||
export const getFirstFrame = (url: string) => {
|
export const getFirstFrame = (url: string, width?: number) => {
|
||||||
if (url.includes('aliyuncs.com')) {
|
if (url.includes('aliyuncs.com')) {
|
||||||
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg';
|
return url + '?x-oss-process=video/snapshot,t_1000,f_jpg' + `${width ? ',w_'+width : ''}`;
|
||||||
} else {
|
} else {
|
||||||
return url + '?vframe/jpg/offset/1';
|
return url + '?vframe/jpg/offset/1' + `${width ? '/w/'+width : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user