246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { FileVideo, FileAudio, FileImage, File, Loader2, Play, Pause, Volume2 } from 'lucide-react';
|
||
import { Material } from '../types/material';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { useLazyLoad } from '../hooks/useLazyLoad';
|
||
|
||
interface MaterialThumbnailProps {
|
||
material: Material;
|
||
size?: 'small' | 'medium' | 'large';
|
||
className?: string;
|
||
thumbnailCache?: Map<string, string>;
|
||
setThumbnailCache?: (cache: Map<string, string>) => void;
|
||
}
|
||
|
||
/**
|
||
* Material缩略图组件
|
||
* 遵循Tauri开发规范的组件设计模式
|
||
* 支持懒加载、缓存机制、错误处理
|
||
*/
|
||
export const MaterialThumbnail: React.FC<MaterialThumbnailProps> = ({
|
||
material,
|
||
size = 'medium',
|
||
className = '',
|
||
thumbnailCache = new Map(),
|
||
setThumbnailCache = () => {},
|
||
}) => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||
const [error, setError] = useState(false);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||
|
||
// 使用懒加载Hook,当缩略图容器可见时才开始加载
|
||
const { isVisible, elementRef } = useLazyLoad(0.1, '100px');
|
||
|
||
// 根据size确定尺寸
|
||
const getSizeClasses = () => {
|
||
switch (size) {
|
||
case 'small':
|
||
return 'w-12 h-12';
|
||
case 'medium':
|
||
return 'w-16 h-16';
|
||
case 'large':
|
||
return 'w-24 h-24';
|
||
default:
|
||
return 'w-16 h-16';
|
||
}
|
||
};
|
||
|
||
// 获取文件类型图标
|
||
const getTypeIcon = () => {
|
||
const iconSize = size === 'small' ? 'w-6 h-6' : size === 'large' ? 'w-12 h-12' : 'w-8 h-8';
|
||
|
||
switch (material.material_type) {
|
||
case 'Video':
|
||
return <FileVideo className={`${iconSize} text-blue-500`} />;
|
||
case 'Audio':
|
||
return <FileAudio className={`${iconSize} text-green-500`} />;
|
||
case 'Image':
|
||
return <FileImage className={`${iconSize} text-purple-500`} />;
|
||
default:
|
||
return <File className={`${iconSize} text-gray-500`} />;
|
||
}
|
||
};
|
||
|
||
// 音频播放控制
|
||
const toggleAudioPlay = async () => {
|
||
if (material.material_type !== 'Audio') return;
|
||
|
||
if (!audioElement) {
|
||
try {
|
||
// 通过后端API获取音频数据
|
||
console.log('获取音频数据:', material.id);
|
||
const audioDataUrl = await invoke<string>('get_audio_file_base64', {
|
||
materialId: material.id
|
||
});
|
||
console.log('获取音频数据成功');
|
||
|
||
// 创建音频元素
|
||
const audio = new Audio();
|
||
audio.src = audioDataUrl;
|
||
audio.onended = () => setIsPlaying(false);
|
||
audio.onerror = () => {
|
||
console.error('音频播放失败');
|
||
setIsPlaying(false);
|
||
};
|
||
setAudioElement(audio);
|
||
|
||
await audio.play();
|
||
setIsPlaying(true);
|
||
} catch (error) {
|
||
console.error('音频播放失败:', error);
|
||
setIsPlaying(false);
|
||
}
|
||
} else {
|
||
if (isPlaying) {
|
||
audioElement.pause();
|
||
setIsPlaying(false);
|
||
} else {
|
||
try {
|
||
await audioElement.play();
|
||
setIsPlaying(true);
|
||
} catch (error) {
|
||
console.error('音频播放失败:', error);
|
||
setIsPlaying(false);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 清理音频元素
|
||
useEffect(() => {
|
||
return () => {
|
||
if (audioElement) {
|
||
audioElement.pause();
|
||
audioElement.src = '';
|
||
}
|
||
};
|
||
}, [audioElement]);
|
||
|
||
useEffect(() => {
|
||
// 只有当元素可见时才加载内容
|
||
if (!isVisible) return;
|
||
|
||
const loadContent = async () => {
|
||
const materialId = material.id;
|
||
|
||
// 检查缓存
|
||
if (thumbnailCache.has(materialId)) {
|
||
const cachedUrl = thumbnailCache.get(materialId);
|
||
setThumbnailUrl(cachedUrl || null);
|
||
return;
|
||
}
|
||
|
||
// 根据素材类型加载不同内容
|
||
if (material.material_type === 'Video') {
|
||
// 视频:生成缩略图
|
||
setLoading(true);
|
||
setError(false);
|
||
|
||
try {
|
||
console.log('获取视频缩略图:', materialId);
|
||
const dataUrl = await invoke<string>('get_material_thumbnail_base64', {
|
||
materialId: materialId
|
||
});
|
||
console.log('获取缩略图成功');
|
||
setThumbnailUrl(dataUrl);
|
||
|
||
// 更新缓存
|
||
const newCache = new Map(thumbnailCache);
|
||
newCache.set(materialId, dataUrl);
|
||
setThumbnailCache(newCache);
|
||
} catch (error) {
|
||
console.error('获取缩略图失败:', error);
|
||
setError(true);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
} else if (material.material_type === 'Image') {
|
||
// 图片:通过后端API获取base64数据
|
||
setLoading(true);
|
||
setError(false);
|
||
|
||
try {
|
||
console.log('获取图片数据:', materialId);
|
||
const dataUrl = await invoke<string>('get_material_thumbnail_base64', {
|
||
materialId: materialId
|
||
});
|
||
console.log('获取图片数据成功');
|
||
setThumbnailUrl(dataUrl);
|
||
|
||
// 更新缓存
|
||
const newCache = new Map(thumbnailCache);
|
||
newCache.set(materialId, dataUrl);
|
||
setThumbnailCache(newCache);
|
||
} catch (error) {
|
||
console.error('获取图片数据失败:', error);
|
||
setError(true);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
// 音频类型不需要加载缩略图,直接显示播放控件
|
||
};
|
||
|
||
loadContent();
|
||
}, [isVisible, material.id, material.material_type, material.original_path, thumbnailCache, setThumbnailCache]);
|
||
|
||
// 渲染音频播放控件
|
||
const renderAudioPlayer = () => {
|
||
const iconSize = size === 'small' ? 'w-4 h-4' : size === 'large' ? 'w-8 h-8' : 'w-6 h-6';
|
||
|
||
return (
|
||
<div className="relative w-full h-full bg-gradient-to-br from-green-50 to-green-100 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:from-green-100 hover:to-green-200 transition-colors"
|
||
onClick={toggleAudioPlay}>
|
||
<Volume2 className={`${iconSize} text-green-600 mb-1`} />
|
||
<div className="flex items-center justify-center">
|
||
{isPlaying ? (
|
||
<Pause className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-5 h-5' : 'w-4 h-4'} text-green-700`} />
|
||
) : (
|
||
<Play className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-5 h-5' : 'w-4 h-4'} text-green-700`} />
|
||
)}
|
||
</div>
|
||
{size !== 'small' && (
|
||
<div className="text-xs text-green-600 mt-1 font-medium">
|
||
{isPlaying ? '播放中' : '点击播放'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={elementRef}
|
||
className={`${getSizeClasses()} bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${className}`}
|
||
>
|
||
{loading ? (
|
||
<Loader2 className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-6 h-6' : 'w-4 h-4'} animate-spin text-blue-600`} />
|
||
) : material.material_type === 'Audio' ? (
|
||
// 音频:显示播放控件
|
||
renderAudioPlayer()
|
||
) : thumbnailUrl && !error ? (
|
||
// 图片和视频:显示图片/缩略图
|
||
<img
|
||
src={thumbnailUrl}
|
||
alt={`${material.name} ${material.material_type === 'Image' ? '图片' : '缩略图'}`}
|
||
className="w-full h-full object-cover rounded-lg"
|
||
onError={() => {
|
||
setError(true);
|
||
setThumbnailUrl(null);
|
||
}}
|
||
/>
|
||
) : isVisible ? (
|
||
// 加载失败或其他类型:显示类型图标
|
||
getTypeIcon()
|
||
) : (
|
||
// 未加载时显示占位符
|
||
<div className={`${size === 'small' ? 'w-6 h-6' : size === 'large' ? 'w-12 h-12' : 'w-8 h-8'} bg-gray-200 rounded flex items-center justify-center`}>
|
||
<div className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-6 h-6' : 'w-4 h-4'} bg-gray-300 rounded`}></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|