game thumbnails

This commit is contained in:
2025-11-14 09:03:05 +01:00
parent 567d293455
commit 833c8afedf
198 changed files with 129 additions and 71 deletions
+93 -41
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
@@ -9,6 +9,8 @@ import "./App.css";
const FILE_STORAGE = 'launcher-settings.json';
const GAME_DIR_KEY = 'game-directory';
const CHECKING_PEERS_TIMEOUT_MS = 5000;
const FALLBACK_THUMBNAIL =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A';
// enum with install status
enum InstallStatus {
@@ -39,6 +41,39 @@ interface Game {
peer_count: number;
}
interface GameThumbnailProps {
gameId: string;
alt: string;
getThumbnailUrl: (gameId: string) => Promise<string>;
}
const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState('');
useEffect(() => {
let isMounted = true;
const loadThumbnail = async () => {
const url = await getThumbnailUrl(gameId);
if (isMounted) {
setThumbnailUrl(url);
}
};
void loadThumbnail();
return () => {
isMounted = false;
};
}, [gameId, getThumbnailUrl]);
if (!thumbnailUrl) {
return null;
}
return <img src={thumbnailUrl} alt={alt} />;
};
const App = () => {
const [gameItems, setGameItems] = useState<Game[]>([]);
const [searchTerm, setSearchTerm] = useState('');
@@ -46,6 +81,24 @@ const App = () => {
const [currentFilter, setCurrentFilter] = useState<GameFilter>('available');
const [totalPeerCount, setTotalPeerCount] = useState(0);
const checkingPeersTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
const getThumbnailUrl = useCallback(async (gameId: string): Promise<string> => {
// Check cache first
if (thumbnails.has(gameId)) {
return thumbnails.get(gameId)!;
}
try {
const thumbnailUrl = await invoke<string>('get_game_thumbnail', { gameId });
setThumbnails(prev => new Map(prev).set(gameId, thumbnailUrl));
return thumbnailUrl;
} catch {
// Return a small placeholder for missing images
setThumbnails(prev => new Map(prev).set(gameId, FALLBACK_THUMBNAIL));
return FALLBACK_THUMBNAIL;
}
}, [thumbnails]);
const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => {
switch (filter) {
@@ -549,50 +602,49 @@ const App = () => {
No games found matching your search and filters.
</div>
) : null}
{filteredAndSearchedGames.map((item) => {
const uint8Array = new Uint8Array(item.thumbnail);
const binaryString = uint8Array.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
const thumbnailUrl = `data:image/jpeg;base64,${btoa(binaryString)}`;
return (
<div key={item.id} className="item">
<img src={thumbnailUrl} alt={`${item.name} thumbnail`} />
<div className="item-name">{item.name}</div>
<div className="description">
<span className="desc-text">{item.description.slice(0, 10)}</span>
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div>
<div
className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`}
onClick={() => {
if (isUnavailable(item)) {
return;
}
{filteredAndSearchedGames.map((item) => (
<div key={item.id} className="item">
<GameThumbnail
gameId={item.id}
alt={`${item.name} thumbnail`}
getThumbnailUrl={getThumbnailUrl}
/>
<div className="item-name">{item.name}</div>
<div className="description">
<span className="desc-text">{item.description.slice(0, 10)}</span>
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div>
<div
className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`}
onClick={() => {
if (isUnavailable(item)) {
return;
}
if (!item.installed) {
installGame(item.id);
} else if (needsUpdate(item)) {
updateGame(item.id);
} else {
runGame(item.id);
}
}}>
{getActionLabel(item)}
if (!item.installed) {
installGame(item.id);
} else if (needsUpdate(item)) {
updateGame(item.id);
} else {
runGame(item.id);
}
}}>
{getActionLabel(item)}
</div>
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
<div className="status-left">
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}
</div>
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
<div className="status-left">
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}
</div>
<div className="status-right">
{item.peer_count > 0 && (
<span className="peer-count">
👥 {item.peer_count}
</span>
)}
</div>
<div className="status-right">
{item.peer_count > 0 && (
<span className="peer-count">
👥 {item.peer_count}
</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</main>
);