From 7e59035080e9c43fffb6e70c2f717b6361991e47 Mon Sep 17 00:00:00 2001 From: ximu3 Date: Sun, 15 Dec 2024 16:47:24 +0800 Subject: [PATCH] feat: Refactoring the image loading module --- src/main/index.ts | 2 +- src/main/media/services.ts | 5 + src/main/utils/path.ts | 8 + src/main/utils/protocol.ts | 119 ++++++++++++-- src/renderer/index.html | 2 +- .../Config/AttributesDialog/Media/main.tsx | 73 +++------ .../Config/AttributesDialog/Path/main.tsx | 12 +- .../src/components/Game/StartGame.tsx | 14 +- src/renderer/src/components/Game/main.tsx | 31 ++-- .../src/components/Librarybar/GameNav.tsx | 30 ++-- .../Showcase/posters/BigGamePoster.tsx | 96 ++++++----- .../Showcase/posters/CollectionPoster.tsx | 42 ++--- .../Showcase/posters/GamePoster.tsx | 112 ++++++------- src/renderer/src/components/ui/game-image.tsx | 67 ++++++++ src/renderer/src/hooks/index.ts | 1 - src/renderer/src/hooks/mediaSyncStore.ts | 151 ------------------ src/renderer/src/pages/Record/GamePoster.tsx | 43 +++-- src/renderer/src/utils/common.ts | 10 ++ 18 files changed, 402 insertions(+), 416 deletions(-) create mode 100644 src/renderer/src/components/ui/game-image.tsx delete mode 100644 src/renderer/src/hooks/mediaSyncStore.ts diff --git a/src/main/index.ts b/src/main/index.ts index f9295b5..df31465 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -123,7 +123,7 @@ function createWindow(): void { mainWindow.webContents.send('start-game-from-url', launchGameId, gamePath, mode, config) launchGameId = null } - }, 7000) + }, 3000) }) mainWindow.webContents.setWindowOpenHandler((details) => { diff --git a/src/main/media/services.ts b/src/main/media/services.ts index 07a0586..377a8fd 100644 --- a/src/main/media/services.ts +++ b/src/main/media/services.ts @@ -6,6 +6,7 @@ import { saveFileIcon, checkIconExists } from './common' +import { BrowserWindow } from 'electron' import log from 'electron-log/main.js' /** @@ -56,6 +57,8 @@ export async function setMedia( default: throw new Error(`Invalid source type: ${source}`) } + const mainWindow = BrowserWindow.getAllWindows()[0] + mainWindow.webContents.send('reload-db-values', `games/${gameId}/${type}`) } catch (error) { log.error('Failed to set media', { gameId, @@ -77,6 +80,8 @@ export async function setMedia( export async function saveIcon(gameId: string, filePath: string): Promise { try { await saveFileIcon(gameId, filePath) + const mainWindow = BrowserWindow.getAllWindows()[0] + mainWindow.webContents.send('reload-db-values', `games/${gameId}/icon`) } catch (error) { log.error('Failed to save icon', { gameId, diff --git a/src/main/utils/path.ts b/src/main/utils/path.ts index 2510f25..410fe90 100644 --- a/src/main/utils/path.ts +++ b/src/main/utils/path.ts @@ -66,6 +66,14 @@ export async function getDataPath(file: string, forceCreate?: boolean): Promise< } } +export function getDataPathSync(file: string): string { + const basePath = app.isPackaged + ? path.join(app.getPath('userData'), 'app/database') + : path.join(getAppRootPath(), '/dev/database') + + return path.join(basePath, file) +} + /** * Get the logs file path * @returns The path of the logs file diff --git a/src/main/utils/protocol.ts b/src/main/utils/protocol.ts index ee4cc95..c806485 100644 --- a/src/main/utils/protocol.ts +++ b/src/main/utils/protocol.ts @@ -1,36 +1,113 @@ -import { protocol, net, app } from 'electron' +import { protocol, app } from 'electron' +import { getDataPathSync, getAppTempPath } from './path' import path from 'path' +import fse from 'fs-extra' export function setupProtocols(): void { - // Registered app protocol processing - protocol.handle('app', async (request) => { + const IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.ico', '.gif'] + + // 创建缓存目录 + const CACHE_DIR = getAppTempPath('images') + fse.ensureDirSync(CACHE_DIR) + + protocol.handle('img', async (request) => { try { const urlObj = new URL(request.url) const relativePath = decodeURIComponent(urlObj.pathname).replace(/^\//, '') - const filePath = path.resolve(relativePath) + const timestamp = urlObj.searchParams.get('t') || '0' + + const dataPath = getDataPathSync('') + const baseFilePath = path.join(dataPath, relativePath) + + // Use hash to create cached filenames to avoid long paths or special characters. + const cacheKey = Buffer.from(relativePath).toString('base64') + const cachePath = path.join(CACHE_DIR, `${cacheKey}.${timestamp}`) + + // Check the cache + if (await fse.pathExists(cachePath)) { + console.log('Serving from cache:', cachePath) + const cachedData = await fse.readFile(cachePath) + return new Response(cachedData, { + status: 200, + headers: { + 'Content-Type': getContentType(path.extname(baseFilePath)), + 'Cache-Control': 'public, max-age=31536000' + } + }) + } - console.log('Request URL:', request.url) - console.log('File path:', filePath) + // Try all possible extensions + for (const ext of IMAGE_EXTENSIONS) { + const fullPath = baseFilePath + ext - const fileUrl = 'file:///' + filePath.split(path.sep).join('/') - console.log('File URL:', fileUrl) + if (await fse.pathExists(fullPath)) { + try { + // Read the original file + const fileData = await fse.readFile(fullPath) - const response = await net.fetch(fileUrl) + // Write to cache + await fse.writeFile(cachePath, fileData) - if (!response.ok) { - throw new Error(`Failed to load file: ${response.status}`) + return new Response(fileData, { + status: 200, + headers: { + 'Content-Type': getContentType(ext), + 'Cache-Control': 'public, max-age=31536000' + } + }) + } catch (error) { + console.error('Error reading/caching file:', error) + continue + } + } } - return response + throw new Error('No matching image file found') } catch (error) { console.error('Protocol error:', error) - return new Response('Error loading file', { - status: 404 - }) + return new Response('Error loading file', { status: 404 }) } }) - // Registering for vnite protocol processing + // Get the MIME type + function getContentType(ext: string): string { + const mimeTypes: Record = { + '.webp': 'image/webp', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.ico': 'image/x-icon', + '.gif': 'image/gif' + } + return mimeTypes[ext.toLowerCase()] || 'application/octet-stream' + } + + // Clear expired cache + async function cleanupOldCache(): Promise { + try { + const files = await fse.readdir(CACHE_DIR) + const now = Date.now() + + for (const file of files) { + const filePath = path.join(CACHE_DIR, file) + const stats = await fse.stat(filePath) + + // Delete cached files older than 30 days + if (now - stats.mtimeMs > 30 * 24 * 60 * 60 * 1000) { + await fse.remove(filePath) + } + } + } catch (error) { + console.error('Error cleaning up cache:', error) + } + } + + // Clean cache regularly (run once a day) + setInterval(cleanupOldCache, 24 * 60 * 60 * 1000) + + // Run a cleanup on startup as well + cleanupOldCache() + if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('vnite', process.execPath, [path.resolve(process.argv[1])]) @@ -39,3 +116,13 @@ export function setupProtocols(): void { app.setAsDefaultProtocolClient('vnite') } } + +// Export the cache cleanup methods to be called manually if needed. +export async function clearImageCache(): Promise { + const CACHE_DIR = path.join(getDataPathSync(''), '.cache', 'images') + try { + await fse.emptyDir(CACHE_DIR) + } catch (error) { + console.error('Error clearing cache:', error) + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index c1d74d6..fbb299e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/src/renderer/src/components/Game/Config/AttributesDialog/Media/main.tsx b/src/renderer/src/components/Game/Config/AttributesDialog/Media/main.tsx index 92d5e46..1e3a9d7 100644 --- a/src/renderer/src/components/Game/Config/AttributesDialog/Media/main.tsx +++ b/src/renderer/src/components/Game/Config/AttributesDialog/Media/main.tsx @@ -1,6 +1,6 @@ import { cn } from '~/utils' -import { useGameMedia } from '~/hooks' import { Card, CardContent, CardHeader, CardTitle } from '@ui/card' +import { GameImage } from '@ui/game-image' import { Button } from '@ui/button' import { ipcInvoke } from '~/utils' import { toast } from 'sonner' @@ -8,18 +8,6 @@ import { useState } from 'react' import { UrlDialog } from './UrlDialog' export function Media({ gameId }: { gameId: string }): JSX.Element { - const { mediaUrl: icon, refreshMedia: refreshIcon } = useGameMedia({ - gameId: gameId, - type: 'icon' - }) - const { mediaUrl: cover, refreshMedia: refreshCover } = useGameMedia({ - gameId: gameId, - type: 'cover' - }) - const { mediaUrl: background, refreshMedia: refreshBackground } = useGameMedia({ - gameId: gameId, - type: 'background' - }) const [mediaUrl, setMediaUrl] = useState('') const [isUrlDialogOpen, setIsUrlDialogOpen] = useState({ icon: false, @@ -31,17 +19,6 @@ export function Media({ gameId }: { gameId: string }): JSX.Element { toast.promise( async () => { await ipcInvoke('set-game-media', gameId, type, filePath) - switch (type) { - case 'icon': - await refreshIcon() - break - case 'cover': - await refreshCover() - break - case 'background': - await refreshBackground() - break - } }, { loading: `正在设置 ${type}...`, @@ -55,17 +32,6 @@ export function Media({ gameId }: { gameId: string }): JSX.Element { toast.promise( async () => { await ipcInvoke('set-game-media', gameId, type, mediaUrl) - switch (type) { - case 'icon': - await refreshIcon() - break - case 'cover': - await refreshCover() - break - case 'background': - await refreshBackground() - break - } }, { loading: `正在设置 ${type}...`, @@ -107,11 +73,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element { />
- {icon ? ( - icon - ) : ( -
暂无图标
- )} + 暂无图标
} + /> @@ -145,15 +112,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element { />
- {background ? ( - background - ) : ( -
暂无背景
- )} + 暂无背景
} + /> @@ -189,11 +153,12 @@ export function Media({ gameId }: { gameId: string }): JSX.Element { />
- {cover ? ( - cover - ) : ( -
暂无封面
- )} + 暂无封面
} + /> diff --git a/src/renderer/src/components/Game/Config/AttributesDialog/Path/main.tsx b/src/renderer/src/components/Game/Config/AttributesDialog/Path/main.tsx index 9aaf4f8..750f04a 100644 --- a/src/renderer/src/components/Game/Config/AttributesDialog/Path/main.tsx +++ b/src/renderer/src/components/Game/Config/AttributesDialog/Path/main.tsx @@ -1,5 +1,5 @@ import { cn } from '~/utils' -import { useDBSyncedState, useGameMedia } from '~/hooks' +import { useDBSyncedState } from '~/hooks' import { Card, CardContent, CardHeader, CardTitle } from '@ui/card' import { Select, @@ -12,7 +12,7 @@ import { } from '@ui/select' import { Input } from '@ui/input' import { Button } from '@ui/button' -import { ipcInvoke, ipcSend } from '~/utils' +import { ipcInvoke, ipcSend, canAccessImageFile } from '~/utils' import { ArrayTextarea } from '@ui/array-textarea' export function Path({ gameId }: { gameId: string }): JSX.Element { @@ -29,11 +29,7 @@ export function Path({ gameId }: { gameId: string }): JSX.Element { 'fileConfig', 'timerPath' ]) - const { mediaUrl: icon, refreshMedia } = useGameMedia({ - gameId, - type: 'icon', - noToastError: true - }) + const [launcherMode] = useDBSyncedState('file', `games/${gameId}/launcher.json`, ['mode']) const [timerPath] = useDBSyncedState('', `games/${gameId}/launcher.json`, [ `${launcherMode}Config`, @@ -58,9 +54,9 @@ export function Path({ gameId }: { gameId: string }): JSX.Element { return } await setGamePath(filePath) + const icon = await canAccessImageFile(gameId, 'icon') if (!icon) { await ipcInvoke('save-game-icon', gameId, filePath) - await refreshMedia() } if (!timerPath) { ipcSend('launcher-preset', 'default', gameId) diff --git a/src/renderer/src/components/Game/StartGame.tsx b/src/renderer/src/components/Game/StartGame.tsx index cd8f68e..b8b6332 100644 --- a/src/renderer/src/components/Game/StartGame.tsx +++ b/src/renderer/src/components/Game/StartGame.tsx @@ -1,5 +1,5 @@ -import { cn } from '~/utils' -import { useDBSyncedState, useGameMedia } from '~/hooks' +import { cn, canAccessImageFile } from '~/utils' +import { useDBSyncedState } from '~/hooks' import { Button } from '@ui/button' import { ipcSend, ipcInvoke } from '~/utils' import { toast } from 'sonner' @@ -15,11 +15,7 @@ export function StartGame({ const [mode] = useDBSyncedState('file', `games/${gameId}/launcher.json`, ['mode']) const [gamePath, setGamePath] = useDBSyncedState('', `games/${gameId}/path.json`, ['gamePath']) const { runningGames, setRunningGames } = useRunningGames() - const { mediaUrl: icon, refreshMedia } = useGameMedia({ - gameId, - type: 'icon', - noToastError: true - }) + const [fileConfig] = useDBSyncedState( { path: '', @@ -56,9 +52,9 @@ export function StartGame({ toast.warning('请先设置游戏路径') const filePath: string = await ipcInvoke('select-path-dialog', ['openFile']) await setGamePath(filePath) - if (!icon) { + const isIconAccessible = await canAccessImageFile(gameId, 'icon') + if (!isIconAccessible) { await ipcInvoke('save-game-icon', gameId, filePath) - await refreshMedia() } ipcSend('launcher-preset', 'default', gameId) return diff --git a/src/renderer/src/components/Game/main.tsx b/src/renderer/src/components/Game/main.tsx index d3dbf67..093b250 100644 --- a/src/renderer/src/components/Game/main.tsx +++ b/src/renderer/src/components/Game/main.tsx @@ -1,38 +1,45 @@ import { cn } from '~/utils' -import { useGameMedia } from '~/hooks' import { ScrollArea } from '@ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@ui/tabs' +import { GameImage } from '@ui/game-image' import { Overview } from './Overview' import { Record } from './Record' import { Save } from './Save' import { Header } from './Header' +import { useState } from 'react' export function Game({ gameId }: { gameId: string }): JSX.Element { - const { mediaUrl: background } = useGameMedia({ gameId, type: 'background' }) + const [isImageError, setIsImageError] = useState(false) return (
-
+
- {background && ( - <> - -
- - )} + <> + setIsImageError(true)} + onUpdated={() => setIsImageError(false)} + fallback={ +
+ } + /> +
-
+
{/* The content is placed here */} -
+
diff --git a/src/renderer/src/components/Librarybar/GameNav.tsx b/src/renderer/src/components/Librarybar/GameNav.tsx index 7753fbf..781dd3b 100644 --- a/src/renderer/src/components/Librarybar/GameNav.tsx +++ b/src/renderer/src/components/Librarybar/GameNav.tsx @@ -1,16 +1,14 @@ import { Nav } from '../ui/nav' import { cn } from '~/utils' -import { useGameMedia, useDBSyncedState } from '~/hooks' +import { useDBSyncedState } from '~/hooks' import { GameNavCM } from '../contextMenu/GameNavCM' import { ContextMenu, ContextMenuTrigger } from '@ui/context-menu' +import { GameImage } from '@ui/game-image' import { AttributesDialog } from '../Game/Config/AttributesDialog' import React from 'react' import { AddCollectionDialog } from '../dialog/AddCollectionDialog' export function GameNav({ gameId, groupId }: { gameId: string; groupId: string }): JSX.Element { - const { mediaUrl: icon } = useGameMedia({ gameId, type: 'icon', noToastError: true }) - const { mediaUrl: _cover } = useGameMedia({ gameId, type: 'cover', noToastError: true }) - const { mediaUrl: _background } = useGameMedia({ gameId, type: 'background', noToastError: true }) const [gameName] = useDBSyncedState('', `games/${gameId}/metadata.json`, ['name']) const [isAttributesDialogOpen, setIsAttributesDialogOpen] = React.useState(false) const [isAddCollectionDialogOpen, setIsAddCollectionDialogOpen] = React.useState(false) @@ -24,19 +22,17 @@ export function GameNav({ gameId, groupId }: { gameId: string; groupId: string } to={`./games/${gameId}/${groupId}`} >
- {icon ? ( -
- icon -
- ) : ( - - )} +
+ } + /> +
{gameName}
diff --git a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx index d9dd850..4839472 100644 --- a/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx +++ b/src/renderer/src/components/Showcase/posters/BigGamePoster.tsx @@ -1,10 +1,11 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@ui/hover-card' import { ContextMenu, ContextMenuTrigger } from '@ui/context-menu' +import { GameImage } from '~/components/ui/game-image' import { HoverBigCardAnimation } from '~/components/animations/HoverBigCard' import { cn } from '~/utils' import { GameNavCM } from '~/components/contextMenu/GameNavCM' import { useNavigate } from 'react-router-dom' -import { useGameMedia, useGameIndexManager, useDBSyncedState } from '~/hooks' +import { useGameIndexManager, useDBSyncedState } from '~/hooks' import { formatTimeToChinese, formatDateToChinese } from '~/utils' import { AttributesDialog } from '~/components/Game/Config/AttributesDialog' import React from 'react' @@ -18,7 +19,6 @@ export function BigGamePoster({ className?: string }): JSX.Element { const navigate = useNavigate() - const { mediaUrl: background } = useGameMedia({ gameId, type: 'background', noToastError: true }) const { gameIndex } = useGameIndexManager() const [playingTime] = useDBSyncedState(0, `games/${gameId}/record.json`, ['playingTime']) const gameData = gameIndex[gameId] @@ -38,31 +38,29 @@ export function BigGamePoster({ )} > - {background ? ( - navigate(`/library/games/${gameId}/all`)} - src={background} - alt={gameId} - className={cn( - 'w-full h-full cursor-pointer object-cover', - '3xl:w-full 3xl:h-full', - className - )} - /> - ) : ( -
navigate(`/library/games/${gameId}/all`)} - > - {gameName} -
- )} + navigate(`/library/games/${gameId}/all`)} + gameId={gameId} + type="background" + className={cn( + 'w-full h-full cursor-pointer object-cover', + '3xl:w-full 3xl:h-full', + className + )} + fallback={ +
navigate(`/library/games/${gameId}/all`)} + > + {gameName} +
+ } + />
-
@@ -107,43 +105,43 @@ export function BigGamePoster({ '3xl:w-[300px] 3xl:h-[272px]' )} > - {/* 背景图层 */} + {/* background layer */}
- {gameId} +
- {/* 内容区域 */} + {/* content area */}
- {/* 游戏标题 */} + {/* Game Title */}
{gameData?.name}
- {/* 游戏预览图 */} + {/* Game Preview Image */}
- {background ? ( - {`${gameData?.name} - ) : ( -
-
- {gameData?.name} + +
+ {gameData?.name} +
-
- )} + } + />
- {/* 游戏信息 */} + {/* Game Information */}
- {/* 游玩时间 */} + {/* Playing time */}
@@ -151,7 +149,7 @@ export function BigGamePoster({
- {/* 最后运行时间 */} + {/* Last running time */}
diff --git a/src/renderer/src/components/Showcase/posters/CollectionPoster.tsx b/src/renderer/src/components/Showcase/posters/CollectionPoster.tsx index 7554eb0..e45ba7f 100644 --- a/src/renderer/src/components/Showcase/posters/CollectionPoster.tsx +++ b/src/renderer/src/components/Showcase/posters/CollectionPoster.tsx @@ -1,8 +1,9 @@ import { HoverSquareCardAnimation } from '~/components/animations/HoverSquareCard' import { cn } from '~/utils' import { useNavigate } from 'react-router-dom' -import { useGameMedia, useCollections } from '~/hooks' +import { useCollections } from '~/hooks' import { CollectionCM } from '~/components/contextMenu/CollectionCM' +import { GameImage } from '@ui/game-image' export function CollectionPoster({ collctionId, @@ -16,7 +17,6 @@ export function CollectionPoster({ const collectionName = collections[collctionId].name const gameId = collections[collctionId].games[0] const length = collections[collctionId].games.length - const { mediaUrl: cover } = useGameMedia({ gameId, type: 'cover', noToastError: true }) return (
- {cover ? ( - {gameId} - ) : ( -
- )} +
+ } + />
diff --git a/src/renderer/src/components/Showcase/posters/GamePoster.tsx b/src/renderer/src/components/Showcase/posters/GamePoster.tsx index eaddfac..ee6aeb0 100644 --- a/src/renderer/src/components/Showcase/posters/GamePoster.tsx +++ b/src/renderer/src/components/Showcase/posters/GamePoster.tsx @@ -1,10 +1,11 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@ui/hover-card' import { ContextMenu, ContextMenuTrigger } from '@ui/context-menu' +import { GameImage } from '@ui/game-image' import { HoverCardAnimation } from '~/components/animations/HoverCard' import { cn } from '~/utils' import { GameNavCM } from '~/components/contextMenu/GameNavCM' import { useNavigate } from 'react-router-dom' -import { useGameMedia, useGameIndexManager, useDBSyncedState } from '~/hooks' +import { useGameIndexManager, useDBSyncedState } from '~/hooks' import { formatTimeToChinese, formatDateToChinese } from '~/utils' import React from 'react' import { AttributesDialog } from '~/components/Game/Config/AttributesDialog' @@ -20,8 +21,6 @@ export function GamePoster({ className?: string }): JSX.Element { const navigate = useNavigate() - const { mediaUrl: cover } = useGameMedia({ gameId, type: 'cover', noToastError: true }) - const { mediaUrl: background } = useGameMedia({ gameId, type: 'background', noToastError: true }) const { gameIndex } = useGameIndexManager() const gameData = gameIndex[gameId] const [playingTime] = useDBSyncedState(0, `games/${gameId}/record.json`, ['playingTime']) @@ -34,41 +33,41 @@ export function GamePoster({ - {cover ? ( - - navigate( - collectionId - ? `/library/games/${gameId}/collection:${collectionId}` - : `/library/games/${gameId}/all` - ) - } - src={cover} - alt={gameId} - className={cn( - 'w-[148px] h-[222px] cursor-pointer object-cover', - '3xl:w-[176px] 3xl:h-[264px]', - className - )} - /> - ) : ( -
- navigate( - collectionId - ? `/library/games/${gameId}/collection:${collectionId}` - : `/library/games/${gameId}/all` - ) - } - > -
{gameName}
-
- )} + + navigate( + collectionId + ? `/library/games/${gameId}/collection:${collectionId}` + : `/library/games/${gameId}/all` + ) + } + gameId={gameId} + type="cover" + alt={gameId} + className={cn( + 'w-[148px] h-[222px] cursor-pointer object-cover', + '3xl:w-[176px] 3xl:h-[264px]', + className + )} + fallback={ +
+ navigate( + collectionId + ? `/library/games/${gameId}/collection:${collectionId}` + : `/library/games/${gameId}/all` + ) + } + > +
{gameName}
+
+ } + />
@@ -95,7 +94,12 @@ export function GamePoster({ > {/* background layer */}
- {gameId} +
@@ -108,22 +112,22 @@ export function GamePoster({ {/* Game Preview Image */}
- {background ? ( - {`${gameData?.name} - ) : ( -
-
- {gameData?.name} + +
+ {gameData?.name} +
-
- )} + } + />
{/* Game Information */} diff --git a/src/renderer/src/components/ui/game-image.tsx b/src/renderer/src/components/ui/game-image.tsx new file mode 100644 index 0000000..ca121c5 --- /dev/null +++ b/src/renderer/src/components/ui/game-image.tsx @@ -0,0 +1,67 @@ +// renderer/components/GameImage.tsx +import React, { useState, useEffect, ImgHTMLAttributes } from 'react' +import { ipcOnUnique, cn } from '~/utils' + +interface GameImageProps extends Omit, 'src'> { + gameId: string + type: 'background' | 'cover' | 'icon' + onUpdated?: () => void + fallback?: React.ReactNode +} + +export const GameImage: React.FC = ({ + gameId, + type, + className, + onError, + onUpdated, + fallback =
暂无图标
, + ...imgProps +}) => { + const [timestamp, setTimestamp] = useState(Date.now()) + const [hasError, setHasError] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + useEffect(() => { + const handleImageUpdate = (_event: any, updatedPath: string): void => { + if (updatedPath.includes(`games/${gameId}/${type}`)) { + setTimestamp(Date.now()) + setHasError(false) + setIsLoaded(false) + onUpdated?.() + } + } + const remove = ipcOnUnique('reload-db-values', handleImageUpdate) + return (): void => { + remove() + } + }, [gameId, type, onUpdated]) + if (hasError) { + return <>{fallback} + } + return ( +
+ {!isLoaded && ( +
+ )} + setIsLoaded(true)} + onError={(e) => { + setHasError(true) + setIsLoaded(false) + onError?.(e) + }} + {...imgProps} + /> +
+ ) +} diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 30134f4..ac4b4e0 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -2,4 +2,3 @@ export * from './collections' export * from './gameIndex' export * from './dbSyncStore' export * from './records' -export * from './mediaSyncStore' diff --git a/src/renderer/src/hooks/mediaSyncStore.ts b/src/renderer/src/hooks/mediaSyncStore.ts deleted file mode 100644 index 371b5e6..0000000 --- a/src/renderer/src/hooks/mediaSyncStore.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { create } from 'zustand' -import { ipcInvoke, ipcOnUnique } from '~/utils' -import { useMemo } from 'react' - -interface MediaData { - protocol: string - path: string - timestamp: number -} - -type MediaKey = `game_${string}_${MediaType}` - -export type MediaType = 'cover' | 'background' | 'icon' - -interface MediaStore { - media: Record - ensureMediaSubscription: (gameId: string, type: MediaType, defaultProtocol: string) => void - getMediaData: (gameId: string, type: MediaType) => MediaData | undefined - refreshMedia: (gameId: string, type: MediaType, defaultProtocol: string) => Promise -} - -const useMediaStore = create((set, get) => ({ - media: {}, - - ensureMediaSubscription: (gameId, type, defaultProtocol): void => { - const key = `game_${gameId}_${type}` as MediaKey - if (get().media[key]) return // Already subscribed - - const fetchAndSetMedia = async (): Promise => { - try { - const mediaPath = await ipcInvoke('get-game-media-path', gameId, type) - if (mediaPath) { - set((state) => ({ - media: { - ...state.media, - [key]: { - protocol: defaultProtocol, - path: mediaPath, - timestamp: Date.now() - } - } - })) - } else { - throw new Error(`No media path found for game ${gameId} ${type}`) - } - } catch (error) { - console.error('Failed to fetch media:', error) - } - } - - // Set up IPC listener - const removeListener = ipcOnUnique('reload-db-values', async (_event, updatedMediaPath) => { - const match = updatedMediaPath.match(/games\/([^/]+)\/([^.]+)/) - if (!match) return - - const [, updatedGameId, mediaType] = match - if (updatedGameId === gameId && mediaType === type) { - await fetchAndSetMedia() - } - }) - - // Initialize media data - fetchAndSetMedia() - - // Store subscription details - set((state) => ({ - media: { - ...state.media, - [key]: { - ...state.media[key], - removeListener - } - } - })) - }, - - getMediaData: (gameId, type): MediaData | undefined => { - const key = `game_${gameId}_${type}` as MediaKey - return get().media[key] - }, - - refreshMedia: async (gameId, type, defaultProtocol): Promise => { - const key = `game_${gameId}_${type}` as MediaKey - try { - const mediaPath = await ipcInvoke('get-game-media-path', gameId, type) - if (mediaPath) { - set((state) => ({ - media: { - ...state.media, - [key]: { - protocol: defaultProtocol, - path: mediaPath, - timestamp: Date.now() - } - } - })) - } else { - throw new Error(`No media path found for game ${gameId} ${type}`) - } - } catch (error) { - console.error('Failed to refresh media:', error) - } - } -})) - -interface UseGameMediaOptions { - gameId: string - type: MediaType - defaultProtocol?: string - noToastError?: boolean -} - -interface UseGameMediaResult { - mediaUrl: string | undefined - isLoading: boolean - error: Error | null - refreshMedia: () => Promise -} - -export function useGameMedia({ - gameId, - type, - defaultProtocol = 'app' -}: UseGameMediaOptions): UseGameMediaResult { - const { ensureMediaSubscription, getMediaData, refreshMedia } = useMediaStore() - - // Ensure subscription on first render - ensureMediaSubscription(gameId, type, defaultProtocol) - - // Get current media data - const currentMediaData = getMediaData(gameId, type) - - // Construct media URL - const mediaUrl = useMemo(() => { - if (!currentMediaData?.path) return undefined - const normalizedPath = currentMediaData.path.replace(/\\/g, '/') - return `${currentMediaData.protocol}:///${normalizedPath}?t=${currentMediaData.timestamp}` - }, [currentMediaData]) - - // Manual refresh function - const refresh = async (): Promise => { - await refreshMedia(gameId, type, defaultProtocol) - } - - return { - mediaUrl, - isLoading: !currentMediaData, - error: null, // Error handling can be improved based on specific needs - refreshMedia: refresh - } -} diff --git a/src/renderer/src/pages/Record/GamePoster.tsx b/src/renderer/src/pages/Record/GamePoster.tsx index b4a2249..0a21187 100644 --- a/src/renderer/src/pages/Record/GamePoster.tsx +++ b/src/renderer/src/pages/Record/GamePoster.tsx @@ -1,7 +1,8 @@ import { HoverSquareCardAnimation } from '~/components/animations/HoverSquareCard' import { cn } from '~/utils' import { useNavigate } from 'react-router-dom' -import { useGameMedia, useDBSyncedState } from '~/hooks' +import { useDBSyncedState } from '~/hooks' +import { GameImage } from '~/components/ui/game-image' export function GamePoster({ gameId, @@ -19,9 +20,7 @@ export function GamePoster({ fontStyles?: { name: string; additionalInfo: string } }): JSX.Element { const navigate = useNavigate() - const { mediaUrl: cover } = useGameMedia({ gameId, type: 'cover', noToastError: true }) const [gameName] = useDBSyncedState('', `games/${gameId}/metadata.json`, ['name']) - return (
- {cover ? ( - {gameId} - ) : ( -
- )} +
+ } + />
diff --git a/src/renderer/src/utils/common.ts b/src/renderer/src/utils/common.ts index 54bac77..4de4b79 100644 --- a/src/renderer/src/utils/common.ts +++ b/src/renderer/src/utils/common.ts @@ -1,6 +1,16 @@ import * as csstree from 'css-tree' import { HTMLReactParserOptions, Element } from 'html-react-parser' +export async function canAccessImageFile(gameId: string, type: string): Promise { + try { + const response = await fetch(`img:///games/${gameId}/${type}`) + return response.ok + } catch (error) { + console.error('Error checking image access:', error) + return false + } +} + interface CSSValidationResult { isValid: boolean error?: string