game thumbnails
@@ -60,7 +60,6 @@ impl From<EtiGame> for Game {
|
||||
genre: eti_game.genre_de,
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
|
||||
thumbnail: None,
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
eti_game_version: None,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
use std::{collections::HashMap, fmt, path::Path};
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Read version from version.ini file
|
||||
@@ -52,8 +51,6 @@ pub struct Game {
|
||||
pub genre: String,
|
||||
/// size in bytes: example: 3455063152
|
||||
pub size: u64,
|
||||
/// thumbnail image
|
||||
pub thumbnail: Option<Bytes>,
|
||||
/// indicates that the ETI bundle exists locally
|
||||
#[serde(default)]
|
||||
pub downloaded: bool,
|
||||
@@ -128,17 +125,6 @@ impl GameDB {
|
||||
db
|
||||
}
|
||||
|
||||
pub fn add_thumbnails(&mut self, thumbs_dir: &Path) {
|
||||
for game in self.games.values_mut() {
|
||||
let asset = thumbs_dir.join(format!("{}.jpg", game.id));
|
||||
if let Ok(data) = std::fs::read(&asset) {
|
||||
game.thumbnail = Some(Bytes::from(data));
|
||||
} else {
|
||||
tracing::warn!("Thumbnail missing: {}", game.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
|
||||
where
|
||||
|
||||
@@ -1251,7 +1251,6 @@ async fn load_local_game_db(game_dir: &str) -> eyre::Result<GameDB> {
|
||||
version: "1.0".to_string(),
|
||||
genre: String::new(),
|
||||
size,
|
||||
thumbnail: None,
|
||||
downloaded,
|
||||
installed,
|
||||
eti_game_version: local_version.clone(),
|
||||
|
||||
@@ -34,6 +34,7 @@ lanspread-mdns = { path = "../../lanspread-mdns" }
|
||||
lanspread-compat = { path = "../../lanspread-compat" }
|
||||
|
||||
# external
|
||||
base64 = { workspace = true }
|
||||
eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mimalloc = { workspace = true }
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -268,6 +268,23 @@ async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Resul
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_game_thumbnail(
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> tauri::Result<String> {
|
||||
use base64::Engine;
|
||||
|
||||
let resource_path = app_handle.path().resolve(
|
||||
format!("assets/{game_id}.jpg"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
|
||||
let image_data = std::fs::read(&resource_path)?;
|
||||
let base64_data = base64::engine::general_purpose::STANDARD.encode(&image_data);
|
||||
Ok(format!("data:image/jpeg;base64,{base64_data}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||
@@ -817,7 +834,8 @@ pub fn run() {
|
||||
run_game,
|
||||
update_game_directory,
|
||||
update_game,
|
||||
get_peer_count
|
||||
get_peer_count,
|
||||
get_game_thumbnail
|
||||
])
|
||||
.manage(lanspread_state)
|
||||
.setup({
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"binaries/unrar"
|
||||
],
|
||||
"resources": [
|
||||
"game.db"
|
||||
"game.db",
|
||||
"assets/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||