refactor(video): split video hooks into provider-specific and global logic

Move provider-specific video management to useProviderVideos hook
Simplify useVideos to handle global video operations
This commit is contained in:
icarus 2025-10-13 21:52:34 +08:00
parent e88b4c091d
commit 2fab33de41
3 changed files with 192 additions and 148 deletions

View File

@ -0,0 +1,158 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
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 { useCallback, useEffect, useRef } from 'react'
import useSWR from 'swr'
import { useVideos } from './useVideos'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useProviderVideos = (providerId: string) => {
const { removeVideo } = useVideos()
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
useEffect(() => {
videosRef.current = videos
}, [videos])
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
)
const removeProviderVideo = useCallback(
(videoId: string) => {
removeVideo(videoId, providerId)
},
[providerId, removeVideo]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
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)
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail } = useVideoThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
}
})
}, [data, error, provider, providerId, retrieveThumbnail, setVideo])
return {
videos: videos ?? [],
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo: removeProviderVideo
}
}

View File

@ -1,7 +1,7 @@
import { useVideos } from './useVideos'
import { useProviderVideos } from './useProviderVideos'
export const useVideo = (providerId: string, id: string) => {
const { videos } = useVideos(providerId)
const { videos } = useProviderVideos(providerId)
const video = videos.find((v) => v.id === id)
return video
}

View File

@ -1,162 +1,48 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addVideoAction,
removeVideoAction,
setVideoAction,
setVideosAction,
updateVideoAction
} from '@renderer/store/video'
import { Video } from '@renderer/types/video'
import { useCallback, useEffect, useRef } from 'react'
import useSWR from 'swr'
import { removeVideoAction } from '@renderer/store/video'
import { objectValues } from '@renderer/types'
import { useCallback } from 'react'
import { useRetrieveThumbnail } from './useRetrieveThumbnail'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useVideos = (providerId: string) => {
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
export const useVideos = () => {
const videoMap = useAppSelector((state) => state.video.videoMap)
const dispatch = useAppDispatch()
useEffect(() => {
videosRef.current = videos
}, [videos])
const { removeThumbnail } = useVideoThumbnail()
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const videos = objectValues(videoMap)
.flat()
.filter((v) => v !== undefined)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
const getVideo = useCallback(
(videoId: string) => {
return videos.find((v) => v.id === videoId)
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
[videos]
)
const removeVideo = useCallback(
(videoId: string) => {
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, providerId]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
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)
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const retrieveThumbnail = useRetrieveThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
(videoId: string, providerId?: string) => {
const video = getVideo(videoId)
if (!video) {
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
if (!providerId) {
providerId = video.providerId
}
})
}, [data, error, provider, providerId, setVideo])
// should delete from redux state, and related thumbnail image, video file
if (video.thumbnail) {
removeThumbnail(videoId)
}
if (video.fileId) {
FileManager.deleteFile(video.fileId)
}
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, getVideo, removeThumbnail]
)
return {
videos: videos ?? [],
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo
}
return { videos, getVideo, removeVideo }
}