feat: Add video playback and image preview support to share page

This commit is contained in:
ShiYu
2025-09-07 11:05:10 +08:00
parent 19c4394f3d
commit dffcdb7a8b
4 changed files with 110 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import { memo, useState, useEffect, useCallback } from 'react'; import { memo, useState, useEffect, useCallback } from 'react';
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd'; import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons'; import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share'; import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs'; import { type VfsEntry } from '../../api/vfs';
@@ -11,9 +11,10 @@ interface DirectoryViewerProps {
token: string; token: string;
shareInfo: ShareInfo; shareInfo: ShareInfo;
password?: string; password?: string;
onFileClick: (entry: VfsEntry, path: string) => void;
} }
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) { export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<VfsEntry[]>([]); const [entries, setEntries] = useState<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState('/');
@@ -38,11 +39,11 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
}, [loadData, currentPath]); }, [loadData, currentPath]);
const handleEntryClick = (entry: VfsEntry) => { const handleEntryClick = (entry: VfsEntry) => {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
if (entry.is_dir) { if (entry.is_dir) {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
loadData(newPath); loadData(newPath);
} else { } else {
message.info('暂不支持预览'); onFileClick(entry, newPath);
} }
}; };

View File

@@ -1,21 +1,27 @@
import { memo, useState, useEffect } from 'react'; import { memo, useState, useEffect } from 'react';
import { Card, Spin, Button, Typography, Empty } from 'antd'; import { Card, Spin, Button, Typography, Empty } from 'antd';
import { DownloadOutlined } from '@ant-design/icons'; import { DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share'; import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs'; import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { VideoViewer } from './VideoViewer';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const isImageViewer = (name: string) => /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name);
const isVideoViewable = (name: string) => /\.(mp4|webm|ogg|m4v|mov)$/i.test(name);
interface FileViewerProps { interface FileViewerProps {
token: string; token: string;
shareInfo: ShareInfo; shareInfo: ShareInfo;
entry: VfsEntry; entry: VfsEntry;
password?: string; password?: string;
onBack: () => void;
path: string;
} }
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) { export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password, onBack, path }: FileViewerProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [content, setContent] = useState<string>(''); const [content, setContent] = useState<string>('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -25,7 +31,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const url = shareApi.downloadUrl(token, entry.name, password); const url = shareApi.downloadUrl(token, path, password);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error('无法加载文件'); throw new Error('无法加载文件');
@@ -44,7 +50,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
} else { } else {
setLoading(false); setLoading(false);
} }
}, [token, entry.name, password]); }, [token, entry.name, password, path]);
const renderContent = () => { const renderContent = () => {
if (loading) { if (loading) {
@@ -53,9 +59,21 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
if (error) { if (error) {
return <Empty description={error} />; return <Empty description={error} />;
} }
const downloadUrl = shareApi.downloadUrl(token, path, password);
if (isImageViewer(entry.name)) {
return <img src={downloadUrl} alt={entry.name} style={{ maxWidth: '100%' }} />;
}
if (isVideoViewable(entry.name)) {
return <VideoViewer token={token} entry={entry} password={password} path={path} />;
}
if (entry.name.endsWith('.md')) { if (entry.name.endsWith('.md')) {
return <ReactMarkdown>{content}</ReactMarkdown>; return <ReactMarkdown>{content}</ReactMarkdown>;
} }
return ( return (
<Empty <Empty
description={ description={
@@ -64,7 +82,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
<Button <Button
type="primary" type="primary"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)} href={downloadUrl}
download download
> >
@@ -84,10 +102,17 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`} {shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
</Text> </Text>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Button
style={{ marginBottom: 16, marginRight: 8 }}
icon={<ArrowLeftOutlined />}
onClick={onBack}
>
</Button>
<Button <Button
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)} href={shareApi.downloadUrl(token, path, password)}
download download
> >

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useRef } from 'react';
import Artplayer from 'artplayer';
import { shareApi } from '../../api/share';
import type { VfsEntry } from '../../api/vfs';
interface VideoViewerProps {
token: string;
entry: VfsEntry;
password?: string;
path: string;
}
export const VideoViewer: React.FC<VideoViewerProps> = ({ token, entry, password, path }) => {
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
useEffect(() => {
const videoUrl = shareApi.downloadUrl(token, path, password);
if (artRef.current) {
artInstance.current = new Artplayer({
container: artRef.current,
url: videoUrl,
autoplay: true,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
}
return () => {
if (artInstance.current) {
artInstance.current.destroy();
}
};
}, [token, entry.name, password, path]);
return (
<div
ref={artRef}
style={{
width: '100%',
height: '450px',
backgroundColor: '#000'
}}
/>
);
};

View File

@@ -10,7 +10,7 @@ const PublicSharePage = memo(function PublicSharePage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null); const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [entry, setEntry] = useState<VfsEntry | null>(null); const [previewFile, setPreviewFile] = useState<{ entry: VfsEntry, path: string } | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [verified, setVerified] = useState(false); const [verified, setVerified] = useState(false);
@@ -37,7 +37,9 @@ const PublicSharePage = memo(function PublicSharePage() {
const listing = await shareApi.listDir(token, '/', currentPassword); const listing = await shareApi.listDir(token, '/', currentPassword);
if (listing.entries.length === 1) { if (listing.entries.length === 1) {
const singleEntry = listing.entries[0]; const singleEntry = listing.entries[0];
setEntry(singleEntry); if (!singleEntry.is_dir) {
setPreviewFile({ entry: singleEntry, path: '/' + singleEntry.name });
}
} }
} }
@@ -99,11 +101,27 @@ const PublicSharePage = memo(function PublicSharePage() {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>; return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
} }
if (entry && !entry.is_dir) { const handleFileClick = (entry: VfsEntry, path: string) => {
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />; setPreviewFile({ entry, path });
} else { };
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
const handleBack = () => {
setPreviewFile(null);
};
if (previewFile) {
return (
<FileViewer
token={token!}
shareInfo={shareInfo}
entry={previewFile.entry}
password={password}
onBack={handleBack}
path={previewFile.path}
/>
);
} }
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} onFileClick={handleFileClick} />;
}); });
export default PublicSharePage; export default PublicSharePage;