mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-03 06:29:56 +08:00
feat: Add video playback and image preview support to share page
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
下载
|
下载
|
||||||
|
|||||||
50
web/src/pages/PublicSharePage/VideoViewer.tsx
Normal file
50
web/src/pages/PublicSharePage/VideoViewer.tsx
Normal 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'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user