From a0627f76d57ac8118d20692604a8fdf7ed1629e6 Mon Sep 17 00:00:00 2001 From: icarus Date: Mon, 13 Oct 2025 22:21:26 +0800 Subject: [PATCH] feat(video): add thumbnail retrieval functionality - Add new translations for thumbnail operations - Extend video types to support thumbnail operations - Implement thumbnail retrieval hook with error handling - Add thumbnail get action to video list items - Update video page to handle thumbnail retrieval - Enhance provider videos hook with thumbnail support --- .../src/hooks/video/useProviderVideos.ts | 28 +++++++++++++++---- .../src/hooks/video/useVideoThumbnail.ts | 4 +-- src/renderer/src/i18n/locales/en-us.json | 4 +++ src/renderer/src/pages/video/VideoList.tsx | 4 ++- .../src/pages/video/VideoListItem.tsx | 12 ++++++-- src/renderer/src/pages/video/VideoPage.tsx | 24 ++++++++++++++-- src/renderer/src/types/video.ts | 2 ++ 7 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/hooks/video/useProviderVideos.ts b/src/renderer/src/hooks/video/useProviderVideos.ts index 8e92c84aa7..7674a9a700 100644 --- a/src/renderer/src/hooks/video/useProviderVideos.ts +++ b/src/renderer/src/hooks/video/useProviderVideos.ts @@ -4,7 +4,9 @@ import { getProviderById } from '@renderer/services/ProviderService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video' import { Video } from '@renderer/types/video' +import { getErrorMessage } from '@renderer/utils' import { useCallback, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { useVideos } from './useVideos' @@ -17,11 +19,19 @@ export const useProviderVideos = (providerId: string) => { const videos = useAppSelector((state) => state.video.videoMap[providerId]) const videosRef = useRef(videos) const dispatch = useAppDispatch() + const { t } = useTranslation() useEffect(() => { videosRef.current = videos }, [videos]) + const getVideo = useCallback( + (id: string) => { + return videos?.find((v) => v.id === id) + }, + [videos] + ) + const addVideo = useCallback( (video: Video) => { if (videos && videos.every((v) => v.id !== video.id)) { @@ -70,12 +80,16 @@ export const useProviderVideos = (providerId: string) => { const provider = getProviderById(providerId) const fetcher = async () => { if (!videos || !provider) return [] - const openaiVideos = videos - .filter((v) => v.type === 'openai') - .filter((v) => v.status === 'queued' || v.status === 'in_progress') - const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider })) - const result = await Promise.allSettled(jobs) - return result.filter((p) => p.status === 'fulfilled').map((p) => p.value) + if (provider.type === 'openai-response') { + const openaiVideos = videos + .filter((v) => v.type === 'openai') + .filter((v) => v.status === 'queued' || v.status === 'in_progress') + const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider })) + const result = await Promise.allSettled(jobs) + return result.filter((p) => p.status === 'fulfilled').map((p) => p.value) + } else { + throw new Error(`Provider type ${provider.type} is not supported for video status polling`) + } } const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 }) const { retrieveThumbnail } = useVideoThumbnail() @@ -132,6 +146,7 @@ export const useProviderVideos = (providerId: string) => { }) .catch((e) => { logger.error('Failed to get thumbnail', e as Error) + window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) }) }) } break @@ -149,6 +164,7 @@ export const useProviderVideos = (providerId: string) => { return { videos: videos ?? [], + getVideo, addVideo, updateVideo, setVideos, diff --git a/src/renderer/src/hooks/video/useVideoThumbnail.ts b/src/renderer/src/hooks/video/useVideoThumbnail.ts index 488b8ef0bc..b536ef0456 100644 --- a/src/renderer/src/hooks/video/useVideoThumbnail.ts +++ b/src/renderer/src/hooks/video/useVideoThumbnail.ts @@ -15,7 +15,7 @@ export const useVideoThumbnail = () => { }, []) const retrieveThumbnail = useCallback( - async (video: Video): Promise => { + async (video: Video): Promise => { const provider = getProviderById(video.providerId) if (!provider) { throw new Error(`Provider not found for id ${video.providerId}`) @@ -66,10 +66,10 @@ export const useVideoThumbnail = () => { return base64 } catch (e) { logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error) + throw e } finally { pendingSet.delete(thumbnailKey) } - return null }, [getThumbnailKey] ) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b39a455e13..94f4d435da 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4694,6 +4694,10 @@ "queued": "Queued" }, "thumbnail": { + "error": { + "get": "Failed to get thumbnail" + }, + "get": "Get thumbnail", "placeholder": "No thumbnail" }, "title": "Video", diff --git a/src/renderer/src/pages/video/VideoList.tsx b/src/renderer/src/pages/video/VideoList.tsx index 5b9ba8bac1..bb578f6336 100644 --- a/src/renderer/src/pages/video/VideoList.tsx +++ b/src/renderer/src/pages/video/VideoList.tsx @@ -8,9 +8,10 @@ export type VideoListProps = { activeVideoId?: string setActiveVideoId: (id: string | undefined) => void onDelete: (id: string) => void + onGetThumbnail: (id: string) => void } -export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete }: VideoListProps) => { +export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete, onGetThumbnail }: VideoListProps) => { return (
setActiveVideoId(video.id)} onDelete={() => onDelete(video.id)} + onGetThhumbnail={() => onGetThumbnail(video.id)} /> ))}
diff --git a/src/renderer/src/pages/video/VideoListItem.tsx b/src/renderer/src/pages/video/VideoListItem.tsx index 31e935f27f..f485502ac8 100644 --- a/src/renderer/src/pages/video/VideoListItem.tsx +++ b/src/renderer/src/pages/video/VideoListItem.tsx @@ -2,19 +2,21 @@ import { cn, Progress, Spinner } from '@heroui/react' import { DeleteIcon } from '@renderer/components/Icons' import { Video } from '@renderer/types/video' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu' -import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon } from 'lucide-react' +import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon, ImageDownIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' export const VideoListItem = ({ video, isActive, onClick, - onDelete + onDelete, + onGetThhumbnail }: { video: Video isActive: boolean onClick: () => void onDelete: () => void + onGetThhumbnail: () => void }) => { const { t } = useTranslation() @@ -140,6 +142,12 @@ export const VideoListItem = ({
+ {video.thumbnail === null && ( + + + {t('video.thumbnail.get')} + + )} {t('common.delete')} diff --git a/src/renderer/src/pages/video/VideoPage.tsx b/src/renderer/src/pages/video/VideoPage.tsx index ba47e647ec..7fd7daee4d 100644 --- a/src/renderer/src/pages/video/VideoPage.tsx +++ b/src/renderer/src/pages/video/VideoPage.tsx @@ -36,12 +36,12 @@ export const VideoPage = () => { }, options: {} }) - const { videos, removeVideo } = useProviderVideos(providerId) + const { videos, removeVideo, getVideo, updateVideo } = useProviderVideos(providerId) // const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId]) const [activeVideoId, setActiveVideoId] = useState() const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos]) const { setPending } = usePending() - const { removeThumbnail } = useVideoThumbnail() + const { removeThumbnail, retrieveThumbnail } = useVideoThumbnail() const updateParams = useCallback((update: DeepPartial>) => { setParams((prev) => deepUpdate(prev, update)) @@ -106,6 +106,25 @@ export const VideoPage = () => { [afterDeleteVideo, provider, setPending, t] ) + const handleGetThumbnail = useCallback( + async (id: string) => { + const video = getVideo(id) + if (video && video.thumbnail === null) { + try { + const promise = retrieveThumbnail(video) + window.toast.loading({ title: t('video.thumbnail.get'), promise }) + const thumbnail = await promise + if (thumbnail) { + updateVideo({ id: video.id, thumbnail }) + } + } catch (e) { + window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) }) + } + } + }, + [getVideo, retrieveThumbnail, t, updateVideo] + ) + return (
@@ -133,6 +152,7 @@ export const VideoPage = () => { activeVideoId={activeVideoId} setActiveVideoId={setActiveVideoId} onDelete={handleDeleteVideo} + onGetThumbnail={handleGetThumbnail} />
diff --git a/src/renderer/src/types/video.ts b/src/renderer/src/types/video.ts index 1e1aaea582..fbc390dbd8 100644 --- a/src/renderer/src/types/video.ts +++ b/src/renderer/src/types/video.ts @@ -34,12 +34,14 @@ interface OpenAIVideoBase { export interface VideoQueued extends VideoBase { readonly status: 'queued' + thumbnail?: never } export interface VideoInProgress extends VideoBase { readonly status: 'in_progress' /** integer percent */ progress: number + thumbnail?: never } export interface VideoCompleted extends VideoBase { readonly status: 'completed'