diff --git a/src/main/index.ts b/src/main/index.ts index fc16820..619ab41 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,10 @@ import { initLog, log } from './log' import { Media } from './media' import { MediaCache } from './media/cache' import { registerMedia } from './media-ipc' +import { Sessions } from './launch/session' +import { power, type PowerAction } from './win/power' +import { watchGuide } from './win/xinput' +import { str } from './validate' import { Store } from './store' import { registerThemeScheme, Themes } from './themes' @@ -54,12 +58,19 @@ function createWindow(): BrowserWindow { return w } -app.on('second-instance', () => { - if (win) { - if (win.isMinimized()) win.restore() - win.focus() - } -}) +/* Ramène le launcher devant le jeu (Windows refuse souvent un simple focus() : passage temporaire au premier plan absolu) */ +function toFront(): void { + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.setAlwaysOnTop(true, 'screen-saver') + win.focus() + app.focus({ steal: true }) + setTimeout(() => win?.setAlwaysOnTop(false), 400) + emit(win, 'app:front', true) +} + +app.on('second-instance', toFront) app.whenReady().then(() => { store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined) @@ -70,6 +81,9 @@ app.whenReady().then(() => { onSettings: (s: typeof store.settings, before: typeof store.settings) => { if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) if (s.steamGridDbKey !== before.steamGridDbKey) { media.keyChanged(); media.enqueue(media.missing()) } + if (s.kiosk !== before.kiosk) win?.setKiosk(s.kiosk) + if (s.autostart !== before.autostart && app.isPackaged) app.setLoginItemSettings({ openAtLogin: s.autostart, args: ['--autostart'] }) + if (JSON.stringify(s.scanners) !== JSON.stringify(before.scanners) || s.extraFolders.join() !== before.extraFolders.join()) library.rescan() } } const cache = new MediaCache(join(dataDir, 'media')) @@ -78,6 +92,23 @@ app.whenReady().then(() => { const media = new Media(ctx, cache) registerMedia(ctx, media) const library = registerLibrary(ctx, (ids) => media.enqueue(ids)) + + const sessions = new Sessions(ctx, toFront, () => win?.minimize()) + handle('game:launch', async (id) => { + const g = store.games.get(str(id, 300, 'id')) + if (!g) throw new Error('jeu introuvable') + await sessions.launch(g) + }) + handle('game:stop', async () => sessions.stop()) + handle('session:get', async () => sessions.running) + handle('system:quit', async () => app.quit()) + handle('system:power', async (a) => { + if (!['sleep', 'restart', 'shutdown'].includes(a)) throw new Error('action inconnue') + store.flush() + power(a as PowerAction) + }) + // Guide (ou Back + Start) tenu : retour au launcher, même pendant un jeu + watchGuide(() => { if (!win?.isFocused()) toFront() }) themes.registerProtocol() handle('themes:list', async () => themes.list()) themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) diff --git a/src/main/launch/match.ts b/src/main/launch/match.ts new file mode 100644 index 0000000..fb822ed --- /dev/null +++ b/src/main/launch/match.ts @@ -0,0 +1,18 @@ +/* Reconnaît le processus d'un jeu (pur, testé) : un exe situé dans le dossier d'installation, hors outils annexes. */ +import type { Proc } from '../win/procs' + +// processus qui tournent dans le dossier d'un jeu sans être le jeu +const NOT_GAME = /(crash|report|handler|helper|launcher_?updater|unins\d*|setup|redist|vc_?redist|dxsetup|easyanticheat_setup|be_?service|cefsharp|webhelper|ue4prereq|dotnet)[^\\/]*\.exe$/i + +const norm = (p: string) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase() + +export function findGameProcs(procs: Proc[], opts: { installDir?: string; exe?: string; selfPid?: number }): Proc[] { + const dir = opts.installDir ? norm(opts.installDir) + '\\' : null + const exe = opts.exe ? norm(opts.exe) : null + return procs.filter((p) => { + if (p.pid === opts.selfPid) return false + const path = norm(p.path) + if (exe && path === exe) return true + return !!dir && path.startsWith(dir) && path.endsWith('.exe') && !NOT_GAME.test(path) + }) +} diff --git a/src/main/launch/session.ts b/src/main/launch/session.ts new file mode 100644 index 0000000..5ad7484 --- /dev/null +++ b/src/main/launch/session.ts @@ -0,0 +1,101 @@ +/* Lancement d'un jeu et suivi de la session : + 1. lancement (protocole de la plateforme ou exe direct) + 2. attente du vrai processus du jeu (jusqu'à 3 min : mise à jour Steam/Epic, launcher éditeur…) + 3. le launcher passe en arrière-plan et met ses animations en pause + 4. fin du processus = fin de session : temps de jeu enregistré, retour au premier plan */ +import { spawn } from 'node:child_process' +import { dirname } from 'node:path' +import { shell } from 'electron' +import type { Game } from '../../shared/types' +import { emit, type Ctx } from '../ipc' +import { log } from '../log' +import { listProcesses } from '../win/procs' +import { findGameProcs } from './match' + +const L = log('session') +const POLL = 3000, WAIT_START = 180_000, END_GRACE = 2 // 2 relevés vides d'affilée (6 s) = jeu fermé +const SCHEMES = /^(steam|com\.epicgames\.launcher|uplay|origin2?|link2ea|goggalaxy):\/\//i + +/* Arguments en ligne de commande, guillemets respectés */ +export function splitArgs(s?: string): string[] { + return (s?.match(/"[^"]*"|\S+/g) ?? []).map((a) => a.replace(/^"|"$/g, '')) +} + +export interface Running { gameId: string; start: number; tracked: boolean } + +export class Sessions { + running: Running | null = null + private timer: NodeJS.Timeout | null = null + + constructor(private ctx: Ctx, private toFront: () => void, private toBack: () => void) {} + + async launch(g: Game): Promise { + if (this.running) throw new Error('un jeu est déjà lancé') + L.info('lancement', g.name, g.id) + let child: number | undefined + if (g.launchUri && SCHEMES.test(g.launchUri)) await shell.openExternal(g.launchUri) + else if (g.exe && /\.(lnk|url)$/i.test(g.exe)) { const err = await shell.openPath(g.exe); if (err) throw new Error(err) } + else if (g.exe) { + const p = spawn(g.exe, splitArgs(g.args), { cwd: g.workDir || dirname(g.exe), detached: true, stdio: 'ignore', windowsHide: false }) + await new Promise((ok, ko) => { p.once('spawn', ok); p.once('error', ko) }) + child = p.pid + p.unref() + } else throw new Error('aucun moyen de lancer ce jeu') + + const now = Date.now() + this.running = { gameId: g.id, start: now, tracked: false } + g.lastPlayed = now + this.ctx.store.save() + emit(this.ctx.win(), 'session:state', { running: this.running }) + setTimeout(() => this.running?.gameId === g.id && this.toBack(), 1500) + this.watch(g, now, child) + } + + private watch(g: Game, launchedAt: number, childPid?: number): void { + let empty = 0, seenAt = 0 + const tick = () => { + const procs = findGameProcs(listProcesses(), { installDir: g.installDir, exe: g.exe, selfPid: process.pid }) + const alive = procs.length > 0 || (childPid !== undefined && isAlive(childPid)) + if (alive) { + if (!seenAt) { seenAt = Date.now(); this.running = { gameId: g.id, start: seenAt, tracked: true }; emit(this.ctx.win(), 'session:state', { running: this.running }); L.info('processus trouvé', procs.map((p) => p.path)) } + empty = 0 + } else if (seenAt) { + if (++empty >= END_GRACE) return this.end(g, seenAt, Date.now() - POLL * (END_GRACE - 1)) + } else if (Date.now() - launchedAt > WAIT_START) { + L.warn('processus du jeu introuvable, session non comptée', g.name) + return this.end(g, 0, 0) + } + this.timer = setTimeout(tick, POLL) + } + this.timer = setTimeout(tick, POLL) + } + + /* Arrêt du suivi demandé par l'utilisateur (jeu jamais détecté, ou retour forcé) */ + stop(): void { + if (!this.running) return + const g = this.ctx.store.games.get(this.running.gameId) + if (this.timer) clearTimeout(this.timer) + if (g) this.end(g, this.running.tracked ? this.running.start : 0, Date.now()) + } + + private end(g: Game, start: number, end: number): void { + if (this.timer) clearTimeout(this.timer) + this.timer = null + const duration = start ? Math.max(0, Math.round((end - start) / 1000)) : 0 + if (duration > 0) { + g.playtime += duration + g.lastPlayed = end + this.ctx.store.sessions.push({ gameId: g.id, start, end }) + } + this.ctx.store.save() + this.running = null + L.info('fin de session', g.name, duration, 's') + emit(this.ctx.win(), 'session:state', { running: null, last: { gameId: g.id, duration } }) + emit(this.ctx.win(), 'library:changed', this.ctx.store.list()) + this.toFront() + } +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true } catch { return false } +} diff --git a/src/main/scanners/folder.ts b/src/main/scanners/folder.ts new file mode 100644 index 0000000..4db8f95 --- /dev/null +++ b/src/main/scanners/folder.ts @@ -0,0 +1,42 @@ +/* Dossiers de jeux choisis dans les paramètres (ex. D:\Jeux) : chaque sous-dossier est un jeu, son exe principal est le plus gros .exe + qui n'est pas un outil (installeur, rapport de plantage…), cherché sur 3 niveaux. */ +import { createHash } from 'node:crypto' +import { statSync } from 'node:fs' +import { win32 as path } from 'node:path' +import type { Scanner, ScanEnv, ScannedGame } from './types' + +const NOT_GAME = /(unins\d*|setup|install|crash|report|redist|vcredist|dxsetup|helper|updater|launcher_?update|ue4prereq|dotnet|vc_redist)/i + +export function mainExe(env: ScanEnv, dir: string, size: (p: string) => number, depth = 3): string | null { + let best: { p: string; s: number } | null = null + const walk = (d: string, n: number) => { + for (const f of env.list(d)) { + const p = path.join(d, f) + if (/\.exe$/i.test(f)) { + if (NOT_GAME.test(f)) continue + const s = size(p) + if (!best || s > best.s) best = { p, s } + } else if (n > 0 && !f.includes('.') && !/^(redist|_commonredist|directx|support|__installer|engine)$/i.test(f)) walk(p, n - 1) + } + } + walk(dir, depth) + return (best as { p: string } | null)?.p ?? null +} + +export function folderScanner(folders: () => string[], size = (p: string) => { try { return statSync(p).size } catch { return 0 } }): Scanner { + return { + id: 'folder', + name: 'Dossiers', + async scan(env) { + const out: ScannedGame[] = [] + for (const root of folders()) { + for (const sub of env.list(root)) { + const dir = path.join(root, sub), exe = mainExe(env, dir, size) + if (!exe) continue + out.push({ platform: 'folder', platformId: createHash('sha1').update(dir.toLowerCase()).digest('hex').slice(0, 16), name: sub, installDir: dir, exe, workDir: path.dirname(exe), installed: true }) + } + } + return out + } + } +} diff --git a/src/main/scanners/index.ts b/src/main/scanners/index.ts index f2014af..1a15233 100644 --- a/src/main/scanners/index.ts +++ b/src/main/scanners/index.ts @@ -4,6 +4,7 @@ import type { ScannerId, Settings } from '../../shared/types' import { log } from '../log' import { ea } from './ea' import { epic } from './epic' +import { folderScanner } from './folder' import { gog } from './gog' import { regQuery } from './reg' import { steam } from './steam' @@ -28,7 +29,7 @@ export interface ScanResult { const withTimeout = (p: Promise, ms: number) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`délai dépassé (${ms} ms)`)), ms))]) -export async function runScanners(settings: Settings, env: ScanEnv = realEnv, scanners = ALL_SCANNERS): Promise { +export async function runScanners(settings: Settings, env: ScanEnv = realEnv, scanners = [...ALL_SCANNERS, folderScanner(() => settings.extraFolders)]): Promise { const res: ScanResult = { ok: {}, failed: [] } await Promise.all( scanners.filter((s) => settings.scanners[s.id]).map(async (s) => { diff --git a/src/main/win/power.ts b/src/main/win/power.ts new file mode 100644 index 0000000..fa02e15 --- /dev/null +++ b/src/main/win/power.ts @@ -0,0 +1,13 @@ +/* Veille, redémarrage, extinction de Windows */ +import { execFile } from 'node:child_process' + +export type PowerAction = 'sleep' | 'restart' | 'shutdown' + +export function power(a: PowerAction): void { + if (process.platform !== 'win32') return + const run = (cmd: string, args: string[]) => execFile(cmd, args, { windowsHide: true }) + if (a === 'shutdown') run('shutdown', ['/s', '/t', '0']) + if (a === 'restart') run('shutdown', ['/r', '/t', '0']) + // SetSuspendState : met en veille (hibernation si elle est activée dans Windows, voir README) + if (a === 'sleep') run('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,1,0']) +} diff --git a/src/main/win/procs.ts b/src/main/win/procs.ts new file mode 100644 index 0000000..8b8500e --- /dev/null +++ b/src/main/win/procs.ts @@ -0,0 +1,55 @@ +/* Liste des processus avec le chemin de leur exe. Windows : Win32 via koffi (rapide, pas de PowerShell). Linux : /proc (développement). */ +import { readdirSync, readlinkSync } from 'node:fs' +import { createRequire } from 'node:module' +import { log } from '../log' + +export interface Proc { pid: number; path: string } +const L = log('procs') + +type Win = { list: () => Proc[] } +let win: Win | null | undefined + +function loadWin(): Win | null { + try { + const koffi = createRequire(__filename)('koffi') as typeof import('koffi') + const k32 = koffi.load('kernel32.dll') + const EnumProcesses = k32.func('bool __stdcall K32EnumProcesses(void *ids, uint32_t cb, void *needed)') + const OpenProcess = k32.func('void * __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)') + const QueryName = k32.func('bool __stdcall QueryFullProcessImageNameW(void *h, uint32_t flags, void *buf, void *size)') + const CloseHandle = k32.func('bool __stdcall CloseHandle(void *h)') + const QUERY_LIMITED = 0x1000 + return { + list() { + const ids = new Uint32Array(4096), needed = new Uint32Array(1) + if (!EnumProcesses(ids, ids.byteLength, needed)) return [] + const out: Proc[] = [], buf = new Uint16Array(1024), size = new Uint32Array(1) + for (let i = 0; i < needed[0] / 4; i++) { + const pid = ids[i] + if (!pid) continue + const h = OpenProcess(QUERY_LIMITED, false, pid) + if (!h) continue // processus protégé ou système + size[0] = buf.length + if (QueryName(h, 0, buf, size)) out.push({ pid, path: String.fromCharCode(...buf.subarray(0, size[0])) }) + CloseHandle(h) + } + return out + } + } + } catch (e) { + L.error('koffi indisponible, suivi des processus désactivé', e) + return null + } +} + +export function listProcesses(): Proc[] { + if (process.platform === 'win32') { + if (win === undefined) win = loadWin() + return win?.list() ?? [] + } + const out: Proc[] = [] + for (const d of readdirSync('/proc')) { + if (!/^\d+$/.test(d)) continue + try { out.push({ pid: +d, path: readlinkSync(`/proc/${d}/exe`) }) } catch { /* processus d'un autre utilisateur */ } + } + return out +} diff --git a/src/main/win/xinput.ts b/src/main/win/xinput.ts new file mode 100644 index 0000000..77a2ff7 --- /dev/null +++ b/src/main/win/xinput.ts @@ -0,0 +1,41 @@ +/* Manettes Xbox (XInput) lues par le processus principal : marche même quand le launcher est en arrière-plan. + Bouton Guide via XInputGetStateEx (ordinal 100, non documenté mais stable) ; sinon combo Back + Start. */ +import { createRequire } from 'node:module' +import { log } from '../log' + +const L = log('xinput') +const GUIDE = 0x0400, BACK = 0x0020, START = 0x0010 + +export function watchGuide(onHold: () => void, holdMs = 800): () => void { + if (process.platform !== 'win32') return () => {} + let getState: ((i: number, s: { Gamepad?: { wButtons: number } }) => number) | null = null + try { + const koffi = createRequire(__filename)('koffi') as typeof import('koffi') + const GAMEPAD = koffi.struct('XINPUT_GAMEPAD', { wButtons: 'uint16_t', bLeftTrigger: 'uint8_t', bRightTrigger: 'uint8_t', sThumbLX: 'int16_t', sThumbLY: 'int16_t', sThumbRX: 'int16_t', sThumbRY: 'int16_t' }) + const STATE = koffi.struct('XINPUT_STATE', { dwPacketNumber: 'uint32_t', Gamepad: GAMEPAD }) + const lib = koffi.load('xinput1_4.dll') + try { + getState = lib.func('__stdcall', 100, 'uint32_t', ['uint32_t', koffi.out(koffi.pointer(STATE))]) // XInputGetStateEx + } catch { + getState = lib.func('__stdcall', 'XInputGetState', 'uint32_t', ['uint32_t', koffi.out(koffi.pointer(STATE))]) + } + } catch (e) { + L.warn('XInput indisponible', e) + return () => {} + } + let since = 0, fired = false + const t = setInterval(() => { + let held = false + for (let i = 0; i < 4; i++) { + const s: { Gamepad?: { wButtons: number } } = {} + if (getState!(i, s) !== 0) continue // manette absente + const b = s.Gamepad?.wButtons ?? 0 + if (b & GUIDE || (b & BACK && b & START)) held = true + } + const now = Date.now() + if (!held) { since = 0; fired = false; return } + if (!since) since = now + if (!fired && now - since >= holdMs) { fired = true; onHold() } + }, 100) + return () => clearInterval(t) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index af58810..78c62a3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,28 +1,75 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { api } from './api' import { Background } from './components/Background' -import { Keyboard } from './components/Keyboard' -import { HintBar } from './nav/glyphs' -import { NavProvider } from './nav/nav' +import { Modal } from './components/Modal' +import { Splash } from './components/Splash' +import { SystemMenu } from './components/SystemMenu' +import { TopBar } from './components/TopBar' +import { useT } from './i18n/i18n' +import { modalOpen, NavProvider, useNav, useNavCtx } from './nav/nav' +import { play } from './nav/sounds' +import { Collections } from './screens/Collections' +import { GameDetail } from './screens/GameDetail' +import { Home } from './screens/Home' +import { Library } from './screens/Library' +import { Settings } from './screens/Settings' +import { LibraryProvider, useLibrary } from './state/library' +import { RouterProvider, TABS, useRouter } from './state/router' +import { SessionProvider, useSession } from './state/session' import { SettingsProvider } from './state/settings' import { ThemeProvider } from './theme/theme' import './styles/base.css' -function Preview() { - const [kb, setKb] = useState(false) - const [text, setText] = useState('') +function Shell() { + const t = useT() + const { route, tab, setTab, back, focusOnShow } = useRouter() + const { running, error, clearError } = useSession() + const { byId } = useLibrary() + const { focus } = useNavCtx() + const [system, setSystem] = useState(false) + const [splash, setSplash] = useState(true) + + // actions globales : onglets (LB/RB), retour, menu système + useNav((a) => { + if (modalOpen()) return false + if (a === 'lb' || a === 'rb') { play('move'); setTab(TABS[(TABS.indexOf(tab) + (a === 'rb' ? 1 : TABS.length - 1)) % TABS.length]); return true } + if (a === 'back') { if (back()) play('back'); return true } + if (a === 'menu') { play('select'); setSystem(true); return true } + return false + }, true, 0) + const endSplash = useCallback(() => setSplash(false), []) + // retour sur un écran : focus remis sur l'élément quitté + useEffect(() => { + if (!focusOnShow) return + const id = requestAnimationFrame(() => focus(document.querySelector(`[data-nav-id="${CSS.escape(focusOnShow)}"]`))) + return () => cancelAnimationFrame(id) + }, [route, focusOnShow, focus]) + useEffect(() => api.on('app:front', () => setSystem(false)), []) + useEffect(() => { document.documentElement.dataset.route = route.name }, [route]) + + const g = running && byId.get(running.gameId) return ( -
- -

Navigation

-

Orbe Launcher

-

{text || '…'}

-
- {Array.from({ length: 12 }, (_, i) => - {kb && { setText(v); setKb(false) }} onCancel={() => setKb(false)} />} - {!kb && } -
+ {g &&
▶ {t('game.playing', { name: g.name })}{!running.tracked && ' · ' + t('game.waiting')}
} + {system && setSystem(false)} />} + {error && ( + +

{error}

+
+
+ )} + {splash && } + ) } @@ -31,7 +78,13 @@ export function App() { - + + + + + + + diff --git a/src/renderer/src/components/Cover.tsx b/src/renderer/src/components/Cover.tsx new file mode 100644 index 0000000..5de317e --- /dev/null +++ b/src/renderer/src/components/Cover.tsx @@ -0,0 +1,16 @@ +/* Jaquette d'un jeu ; sans image, une jaquette générée au style du thème (nom + plateforme). */ +import { useState } from 'react' +import type { Game } from '@shared/types' +import { PLATFORM_LABEL } from '../state/format' + +export function Cover({ game, kind = 'cover' }: { game: Game; kind?: 'cover' | 'hero' }) { + const [broken, setBroken] = useState() + const src = game.media[kind] + if (src && src !== broken) return setBroken(src)} /> + return ( +
+ {PLATFORM_LABEL[game.platform]} + {game.name} +
+ ) +} diff --git a/src/renderer/src/components/CoverPicker.tsx b/src/renderer/src/components/CoverPicker.tsx new file mode 100644 index 0000000..604e5dc --- /dev/null +++ b/src/renderer/src/components/CoverPicker.tsx @@ -0,0 +1,52 @@ +/* Choix d'une autre jaquette / fond / logo / icône parmi les résultats SteamGridDB (ou Steam), ou depuis un fichier */ +import { useEffect, useState } from 'react' +import type { MediaKind, MediaOption } from '@shared/ipc' +import type { Game } from '@shared/types' +import { api } from '../api' +import { useT } from '../i18n/i18n' +import { useNav } from '../nav/nav' +import { useSettings } from '../state/settings' +import { FileBrowser } from './FileBrowser' +import { Modal } from './Modal' + +const KINDS: MediaKind[] = ['cover', 'hero', 'logo', 'icon'] + +export function CoverPicker({ game, onClose }: { game: Game; onClose: () => void }) { + const t = useT() + const { settings } = useSettings() + const [kind, setKind] = useState('cover') + const [opts, setOpts] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const [file, setFile] = useState(false) + useEffect(() => { setOpts(null); api['media:options'](game.id, kind).then(setOpts).catch((e) => { setOpts([]); setErr(String(e.message ?? e)) }) }, [game.id, kind]) + useNav((a) => { + const i = KINDS.indexOf(kind) + if (a === 'lb') { setKind(KINDS[(i + 3) % 4]); return true } + if (a === 'rb') { setKind(KINDS[(i + 1) % 4]); return true } + return false + }, !file, 2) + const pick = async (url: string) => { + setBusy(true) + try { await api['media:set'](game.id, kind, url); onClose() } catch { setErr(t('media.failed')); setBusy(false) } + } + if (file) return setFile(false)} onPick={async (p) => { await api['media:setFile'](game.id, kind, p); onClose() }} /> + return ( + +
+ {KINDS.map((k) => )} +
+ {!settings.steamGridDbKey &&

{t('media.noKey')}

} + {err &&

{err}

} +
+ + {opts === null &&

{t('media.loading')}

} + {opts?.map((o, i) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/EditGame.tsx b/src/renderer/src/components/EditGame.tsx new file mode 100644 index 0000000..286639e --- /dev/null +++ b/src/renderer/src/components/EditGame.tsx @@ -0,0 +1,55 @@ +/* Modifier un jeu (nom, exe, arguments, dossier de travail) ou en ajouter un à la main. Chaque champ s'édite au clavier virtuel ou à l'explorateur. */ +import { useState } from 'react' +import type { Game } from '@shared/types' +import { api } from '../api' +import { useT } from '../i18n/i18n' +import { FileBrowser } from './FileBrowser' +import { Keyboard } from './Keyboard' +import { Modal } from './Modal' + +type Field = 'name' | 'exe' | 'args' | 'workDir' +interface Values { name: string; exe: string; args: string; workDir: string } + +export function EditGame({ game, initialExe, onClose, onSaved }: { game?: Game; initialExe?: string; onClose: () => void; onSaved?: (g: Game) => void }) { + const t = useT() + const guess = (exe: string) => exe.split(/[\\/]/).pop()?.replace(/\.\w+$/, '') ?? '' + const [v, setV] = useState({ name: game?.name ?? guess(initialExe ?? ''), exe: game?.exe ?? initialExe ?? '', args: game?.args ?? '', workDir: game?.workDir ?? '' }) + const [edit, setEdit] = useState(null) + const [err, setErr] = useState('') + const canExe = !game || game.platform === 'manual' || game.platform === 'gog' || game.platform === 'ea' + + const save = async () => { + try { + const g = game + ? await api['library:update'](game.id, { name: v.name, args: v.args, workDir: v.workDir, ...(canExe ? { exe: v.exe } : {}) }) + : await api['library:addManual']({ name: v.name, exe: v.exe, args: v.args || undefined, workDir: v.workDir || undefined }) + onSaved?.(g) + onClose() + } catch (e) { + setErr(String((e as Error).message ?? e).replace(/^Error invoking remote method '[^']+': (Error: )?/, '')) + } + } + + if (edit === 'exe' || edit === 'workDir') + return setEdit(null)} onPick={(p) => { setV({ ...v, [edit]: p, ...(edit === 'exe' && !v.name ? { name: guess(p) } : {}) }); setEdit(null) }} /> + if (edit) return setEdit(null)} onSubmit={(s) => { setV({ ...v, [edit]: s }); setEdit(null) }} /> + + const fields: Field[] = canExe ? ['name', 'exe', 'args', 'workDir'] : ['name', 'args'] + return ( + +
+ {fields.map((f, i) => ( + + ))} +
+ {err &&

{err}

} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/FileBrowser.tsx b/src/renderer/src/components/FileBrowser.tsx new file mode 100644 index 0000000..01c1279 --- /dev/null +++ b/src/renderer/src/components/FileBrowser.tsx @@ -0,0 +1,36 @@ +/* Explorateur de fichiers navigable à la manette (choix d'un exe, d'une image, d'un dossier) */ +import { useEffect, useState } from 'react' +import type { FsFilter, FsListing } from '@shared/ipc' +import { api } from '../api' +import { useT } from '../i18n/i18n' +import { useNav } from '../nav/nav' +import { Modal } from './Modal' + +export function FileBrowser({ filter, start = '', title, onPick, onClose }: { filter: FsFilter; start?: string; title: string; onPick: (path: string) => void; onClose: () => void }) { + const t = useT() + const [list, setList] = useState(null) + const [err, setErr] = useState('') + const open = (dir: string) => api['fs:list'](dir, filter).then((l) => { setList(l); setErr('') }).catch(() => setErr(t('fs.denied'))) + useEffect(() => { open(start) }, []) + // X = remonter d'un dossier + useNav((a) => { if (a === 'x' && list?.parent !== null && list) { open(list.parent ?? ''); return true } return false }, true, 2) + return ( + + {err &&

{err}

} +
+ {list?.parent !== null && list && ( + + )} + {filter === 'dir' && list?.dir && ( + + )} + {list?.entries.map((e, i) => ( + + ))} + {list && !list.entries.length &&

{t('fs.empty')}

} +
+
+ ) +} diff --git a/src/renderer/src/components/GameCard.tsx b/src/renderer/src/components/GameCard.tsx new file mode 100644 index 0000000..357ac5a --- /dev/null +++ b/src/renderer/src/components/GameCard.tsx @@ -0,0 +1,27 @@ +/* Carte de jeu focalisable */ +import { memo } from 'react' +import type { Game } from '@shared/types' +import { Cover } from './Cover' + +interface Props { + game: Game + onOpen: (g: Game) => void + autofocus?: boolean +} + +export const GameCard = memo(function GameCard({ game, onOpen, autofocus }: Props) { + return ( + + ) +}) diff --git a/src/renderer/src/components/Grid.tsx b/src/renderer/src/components/Grid.tsx new file mode 100644 index 0000000..635ccd4 --- /dev/null +++ b/src/renderer/src/components/Grid.tsx @@ -0,0 +1,47 @@ +/* Grille virtualisée : seules les rangées visibles (+ marge) sont dans le DOM, la navigation reste continue. */ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { Game } from '@shared/types' +import { GameCard } from './GameCard' + +const MIN_W = 200, GAP = 26, OVERSCAN = 2 + +export function Grid({ games, onOpen, initialId }: { games: Game[]; onOpen: (g: Game) => void; initialId?: string }) { + const box = useRef(null) + const [size, setSize] = useState({ w: 1200, h: 800 }) + const [top, setTop] = useState(0) + useLayoutEffect(() => { + const el = box.current! + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })) + ro.observe(el) + return () => ro.disconnect() + }, []) + const cols = Math.max(2, Math.floor((size.w + GAP) / (MIN_W + GAP))) + const cardW = (size.w - GAP * (cols - 1)) / cols, rowH = cardW * 1.5 + GAP + const rows = Math.ceil(games.length / cols) + const first = Math.max(0, Math.floor(top / rowH) - OVERSCAN), last = Math.min(rows, Math.ceil((top + size.h) / rowH) + OVERSCAN) + + // retour sur la grille : faire défiler jusqu'au jeu précédemment choisi pour qu'il soit rendu + useEffect(() => { + if (!initialId) return + const i = games.findIndex((g) => 'g:' + g.id === initialId) + if (i >= 0 && box.current) box.current.scrollTop = Math.max(0, Math.floor(i / cols) * rowH - rowH) + }, [initialId, cols, rowH]) + + const items = [] + for (let r = first; r < last; r++) { + for (let c = 0; c < cols; c++) { + const g = games[r * cols + c] + if (!g) break + items.push( +
+ +
+ ) + } + } + return ( +
setTop(e.currentTarget.scrollTop)}> +
{items}
+
+ ) +} diff --git a/src/renderer/src/components/Keyboard.tsx b/src/renderer/src/components/Keyboard.tsx index 1c681b0..ed27880 100644 --- a/src/renderer/src/components/Keyboard.tsx +++ b/src/renderer/src/components/Keyboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { HintBar } from '../nav/glyphs' import { useNav } from '../nav/nav' +import { play } from '../nav/sounds' import { useSettings } from '../state/settings' import { useT } from '../i18n/i18n' @@ -46,13 +47,13 @@ export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit, }) useNav((a) => { - if (a === 'back') { onCancel(); return true } + if (a === 'back') { play('back'); onCancel(); return true } if (a === 'x') { del(); return true } if (a === 'y') { type(' '); return true } if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true } if (a === 'menu' || a === 'rt') { onSubmit(value); return true } return false - }) + }, true, 2) return (
diff --git a/src/renderer/src/components/ListView.tsx b/src/renderer/src/components/ListView.tsx new file mode 100644 index 0000000..8fa662b --- /dev/null +++ b/src/renderer/src/components/ListView.tsx @@ -0,0 +1,22 @@ +/* Disposition « liste » (proposée par un thème) : une ligne par jeu, jaquette à droite de la ligne focalisée */ +import type { Game } from '@shared/types' +import { useT } from '../i18n/i18n' +import { useSettings } from '../state/settings' +import { ago, playtime, PLATFORM_LABEL } from '../state/format' + +export function ListView({ games, onOpen }: { games: Game[]; onOpen: (g: Game) => void }) { + const t = useT() + const { settings } = useSettings() + return ( +
+ {games.map((g, i) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/Modal.tsx b/src/renderer/src/components/Modal.tsx new file mode 100644 index 0000000..2cbbb90 --- /dev/null +++ b/src/renderer/src/components/Modal.tsx @@ -0,0 +1,49 @@ +/* Fenêtres modales : menu d'options et confirmation. Retour (B) ferme. */ +import type { ReactNode } from 'react' +import { HintBar } from '../nav/glyphs' +import { useNav } from '../nav/nav' +import { play } from '../nav/sounds' + +export function Modal({ title, kicker, children, onClose, wide }: { title?: string; kicker?: string; children: ReactNode; onClose: () => void; wide?: boolean }) { + useNav((a) => { if (a === 'back') { play('back'); onClose(); return true } return false }, true, 2) + return ( +
+
+ {kicker &&

{kicker}

} + {title &&

{title}

} + {children} +
+ +
+ ) +} + +export interface MenuItem { label: string; onSelect: () => void; danger?: boolean; checked?: boolean; hint?: string } + +export function Menu({ title, kicker, items, onClose }: { title?: string; kicker?: string; items: MenuItem[]; onClose: () => void }) { + return ( + +
+ {items.map((it, i) => ( + + ))} +
+
+ ) +} + +export function Confirm({ title, text, ok, cancel, danger, onOk, onClose }: { title: string; text?: string; ok: string; cancel: string; danger?: boolean; onOk: () => void; onClose: () => void }) { + return ( + + {text &&

{text}

} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/Row.tsx b/src/renderer/src/components/Row.tsx new file mode 100644 index 0000000..85c2987 --- /dev/null +++ b/src/renderer/src/components/Row.tsx @@ -0,0 +1,15 @@ +/* Rangée horizontale de cartes (accueil, disposition carrousel) */ +import type { Game } from '@shared/types' +import { GameCard } from './GameCard' + +export function Row({ title, games, onOpen, autofocusFirst }: { title: string; games: Game[]; onOpen: (g: Game) => void; autofocusFirst?: boolean }) { + if (!games.length) return null + return ( +
+

{title}

+
+ {games.map((g, i) => )} +
+
+ ) +} diff --git a/src/renderer/src/components/Splash.tsx b/src/renderer/src/components/Splash.tsx new file mode 100644 index 0000000..298d9b5 --- /dev/null +++ b/src/renderer/src/components/Splash.tsx @@ -0,0 +1,25 @@ +/* Écran de démarrage défini par le thème (orbe, logo, image, vidéo), puis fondu */ +import { useEffect, useState } from 'react' +import { useTheme } from '../theme/theme' +import { Orb } from './Orb' + +export function Splash({ onDone }: { onDone: () => void }) { + const { theme, url } = useTheme() + const [out, setOut] = useState(false) + const s = theme?.manifest.splash + useEffect(() => { + if (!theme) return + const d = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : (s?.duration ?? 0) + const a = setTimeout(() => setOut(true), d), b = setTimeout(onDone, d + 450) + return () => { clearTimeout(a); clearTimeout(b) } + }, [theme]) + if (!theme || !s || s.type === 'none') return null + return ( +
+ {s.type === 'orb' && } + {s.type === 'image' && } + {s.type === 'video' &&
+ ) +} diff --git a/src/renderer/src/components/SystemMenu.tsx b/src/renderer/src/components/SystemMenu.tsx new file mode 100644 index 0000000..6a1a465 --- /dev/null +++ b/src/renderer/src/components/SystemMenu.tsx @@ -0,0 +1,29 @@ +/* Menu système (bouton Menu / Start) : jeu en cours, quitter, veille, redémarrer, éteindre — toujours avec confirmation */ +import { useState } from 'react' +import type { PowerAction } from '@shared/ipc' +import { api } from '../api' +import { useT } from '../i18n/i18n' +import { useLibrary } from '../state/library' +import { useSession } from '../state/session' +import { Confirm, Menu } from './Modal' + +type Ask = { title: string; ok: string; run: () => void } | null + +export function SystemMenu({ onClose }: { onClose: () => void }) { + const t = useT() + const { running, stop } = useSession() + const { byId } = useLibrary() + const [ask, setAsk] = useState(null) + const pw = (a: PowerAction, title: string, ok: string) => setAsk({ title, ok, run: () => api['system:power'](a) }) + if (ask) return setAsk(null)} onOk={() => { ask.run(); setAsk(null); onClose() }} /> + const g = running && byId.get(running.gameId) + return ( + { stop(); onClose() } }] : []), + { label: t('sys.quit'), onSelect: () => setAsk({ title: t('sys.quitQ'), ok: t('sys.quit'), run: () => api['system:quit']() }) }, + { label: t('sys.sleep'), onSelect: () => pw('sleep', t('sys.sleepQ'), t('sys.sleep')) }, + { label: t('sys.restart'), onSelect: () => pw('restart', t('sys.restartQ'), t('sys.restart')) }, + { label: t('sys.shutdown'), danger: true, onSelect: () => pw('shutdown', t('sys.shutdownQ'), t('sys.shutdown')) } + ]} /> + ) +} diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx new file mode 100644 index 0000000..0b3d07a --- /dev/null +++ b/src/renderer/src/components/TopBar.tsx @@ -0,0 +1,32 @@ +/* En-tête : onglets (LB / RB pour changer), heure, indicateur de scan */ +import { useEffect, useState } from 'react' +import { Glyph } from '../nav/glyphs' +import { useT } from '../i18n/i18n' +import { useLibrary } from '../state/library' +import { TABS, useRouter } from '../state/router' +import { useSettings } from '../state/settings' + +export function TopBar() { + const t = useT() + const { tab, setTab } = useRouter() + const { scanning } = useLibrary() + const { settings } = useSettings() + const [now, setNow] = useState(new Date()) + useEffect(() => { const i = setInterval(() => setNow(new Date()), 15000); return () => clearInterval(i) }, []) + return ( +
+ orbe. + + + {scanning && {t('scan.running')}} + {now.toLocaleTimeString(settings.language === 'fr' ? 'fr-FR' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} + +
+ ) +} diff --git a/src/renderer/src/dev/mock.ts b/src/renderer/src/dev/mock.ts index 11a8181..43a43e9 100644 --- a/src/renderer/src/dev/mock.ts +++ b/src/renderer/src/dev/mock.ts @@ -58,6 +58,11 @@ export const mock: OrbeApi = { 'media:set': async (id, kind, url) => { const g = games.find((x) => x.id === id)!; g.media[kind] = url; fire('library:changed', games); return g }, 'media:setFile': async (id) => games.find((x) => x.id === id)!, 'media:refresh': async () => {}, + 'game:launch': async (id) => { fire('session:state', { running: { gameId: id, start: Date.now(), tracked: true } }) }, + 'game:stop': async () => { fire('session:state', { running: null }) }, + 'session:get': async () => null, + 'system:power': async () => {}, + 'system:quit': async () => {}, 'collections:list': async () => collections, 'collections:create': async (name) => { const c = { id: String(Date.now()), name, createdAt: Date.now() } diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts index 0a76169..77ded55 100644 --- a/src/renderer/src/i18n/en.ts +++ b/src/renderer/src/i18n/en.ts @@ -1,14 +1,159 @@ import type { fr } from './fr' export const en: Record = { + 'common.cancel': 'Cancel', + 'common.save': 'Save', + 'common.on': 'On', + 'common.off': 'Off', + 'common.enable': 'Enable', + + 'tab.home': 'Home', + 'tab.library': 'Library', + 'tab.collections': 'Collections', + 'tab.settings': 'Settings', + 'hint.select': 'Select', 'hint.back': 'Back', 'hint.ok': 'Confirm', 'hint.delete': 'Delete', 'hint.space': 'Space', 'hint.shift': 'Shift', + 'hint.sort': 'Sort', + 'hint.system': 'Menu', + 'hint.change': 'Change', + 'kb.title': 'Input', 'kb.ok': 'OK', 'kb.space': 'Space', - 'kb.delete': 'Delete' + 'kb.delete': 'Delete', + + 'time.never': 'Never', + 'time.hm': '{h} h {m}', + 'time.m': '{m} min', + 'time.justNow': 'Just now', + 'time.today': 'Today', + 'time.yesterday': 'Yesterday', + 'time.days': '{n} days ago', + + 'scan.running': 'Scanning…', + 'scan.report': '{total} games, {added} new', + + 'home.kicker': 'Welcome', + 'home.scanning': 'Looking for your games…', + 'home.empty': 'No games found', + 'home.emptyText': 'Steam, Epic, GOG, Ubisoft and EA are detected automatically. You can also add a game from the library.', + 'home.continue': 'Continue', + 'home.pick': 'Pick something', + 'home.open': 'View game', + 'home.recent': 'Recently played', + 'home.favorites': 'Favorites', + 'home.added': 'Recently added', + + 'lib.kicker': 'All your games', + 'lib.collection': 'Collection', + 'lib.search': 'Search', + 'lib.add': 'Add a game', + 'lib.rescan': 'Rescan', + 'lib.empty': 'No games here.', + 'lib.noResult': 'No game matches “{q}”.', + 'lib.pickExe': 'Choose the game executable', + + 'filter.all': 'All', + 'filter.installed': 'Installed', + 'filter.favorites': 'Favorites', + 'sort.name': 'Name', + 'sort.playtime': 'Play time', + 'sort.recent': 'Last played', + 'sort.added': 'Date added', + + 'coll.kicker': 'Your shelves', + 'coll.new': 'New collection', + 'coll.none': 'Create a collection, then add games from their page.', + 'coll.count': '{n} games', + 'coll.empty': 'Empty collection: add games from their page.', + 'coll.options': 'Options', + 'coll.rename': 'Rename', + 'coll.delete': 'Delete collection', + + 'game.missing': 'This game no longer exists.', + 'game.notInstalled': 'not installed', + 'game.playtime': 'Play time', + 'game.last': 'Last session', + 'game.release': 'Released', + 'game.dev': 'Studio', + 'game.play': 'Play', + 'game.running': 'Running', + 'game.fav': 'Favorite', + 'game.unfav': 'Remove from favorites', + 'game.collections': 'Collections', + 'game.more': 'More', + 'game.edit': 'Edit', + 'game.media': 'Change artwork', + 'game.refreshMedia': 'Download artwork again', + 'game.remove': 'Remove', + 'game.hide': 'Hide', + 'game.removeQ': 'Remove this game from the launcher?', + 'game.removeText': 'The game stays on disk, only its entry is removed.', + 'game.hideQ': 'Hide this game?', + 'game.hideText': 'It will not show up again, even after a new scan.', + 'game.playing': 'Playing: {name}', + 'game.waiting': 'waiting for the game', + 'game.launchError': 'Could not launch', + + 'edit.title': 'Edit game', + 'edit.addTitle': 'Add a game', + 'edit.name': 'Name', + 'edit.exe': 'Executable', + 'edit.args': 'Arguments', + 'edit.workDir': 'Working folder', + 'edit.empty': 'Not set', + + 'media.title': 'Game artwork', + 'media.cover': 'Cover', + 'media.hero': 'Background', + 'media.logo': 'Logo', + 'media.icon': 'Icon', + 'media.fromFile': 'From a file', + 'media.loading': 'Loading…', + 'media.noKey': 'Add a SteamGridDB key in settings for more choices.', + 'media.failed': 'Download failed.', + + 'fs.drives': 'Drives', + 'fs.up': 'Parent folder', + 'fs.pickDir': 'Choose this folder', + 'fs.empty': 'Nothing here.', + 'fs.denied': 'Folder not accessible.', + + 'set.kicker': 'Preferences', + 'set.display': 'Display and sound', + 'set.theme': 'Theme', + 'set.language': 'Language', + 'set.fullscreen': 'Fullscreen', + 'set.volume': 'Sound volume', + 'set.vibration': 'Controller vibration', + 'set.system': 'System', + 'set.autostart': 'Start with Windows', + 'set.kiosk': 'Kiosk mode', + 'set.kioskHint': 'Can only be closed from the system menu', + 'set.kioskWarn': 'The launcher stays fullscreen and cannot be closed with the keyboard. To fully replace the Windows desktop, follow the README.', + 'set.library': 'Library', + 'set.sgdb': 'SteamGridDB API key', + 'set.sgdbHint': 'steamgriddb.com, profile, preferences, API', + 'set.none': 'None', + 'set.folders': 'Game folders', + 'set.addFolder': 'Add a game folder', + 'set.removeFolder': 'Remove', + 'set.about': 'About', + + 'sys.title': 'Menu', + 'sys.stopTracking': 'Back to the launcher ({name})', + 'sys.stopHint': 'stops time tracking', + 'sys.quit': 'Quit the launcher', + 'sys.quitQ': 'Quit Orbe Launcher?', + 'sys.sleep': 'Sleep', + 'sys.sleepQ': 'Put the PC to sleep?', + 'sys.restart': 'Restart', + 'sys.restartQ': 'Restart the PC?', + 'sys.shutdown': 'Shut down', + 'sys.shutdownQ': 'Shut down the PC?' } diff --git a/src/renderer/src/i18n/fr.ts b/src/renderer/src/i18n/fr.ts index 940abf2..8a54d98 100644 --- a/src/renderer/src/i18n/fr.ts +++ b/src/renderer/src/i18n/fr.ts @@ -1,12 +1,157 @@ export const fr = { + 'common.cancel': 'Annuler', + 'common.save': 'Enregistrer', + 'common.on': 'Activé', + 'common.off': 'Désactivé', + 'common.enable': 'Activer', + + 'tab.home': 'Accueil', + 'tab.library': 'Bibliothèque', + 'tab.collections': 'Collections', + 'tab.settings': 'Paramètres', + 'hint.select': 'Choisir', 'hint.back': 'Retour', 'hint.ok': 'Valider', 'hint.delete': 'Effacer', 'hint.space': 'Espace', 'hint.shift': 'Majuscules', + 'hint.sort': 'Trier', + 'hint.system': 'Menu', + 'hint.change': 'Changer', + 'kb.title': 'Saisie', 'kb.ok': 'OK', 'kb.space': 'Espace', - 'kb.delete': 'Effacer' + 'kb.delete': 'Effacer', + + 'time.never': 'Jamais', + 'time.hm': '{h} h {m}', + 'time.m': '{m} min', + 'time.justNow': 'À l’instant', + 'time.today': 'Aujourd’hui', + 'time.yesterday': 'Hier', + 'time.days': 'Il y a {n} jours', + + 'scan.running': 'Scan en cours…', + 'scan.report': '{total} jeux, {added} nouveaux', + + 'home.kicker': 'Bienvenue', + 'home.scanning': 'Recherche de vos jeux…', + 'home.empty': 'Aucun jeu trouvé', + 'home.emptyText': 'Steam, Epic, GOG, Ubisoft et EA sont détectés tout seuls. Vous pouvez aussi ajouter un jeu depuis la bibliothèque.', + 'home.continue': 'Reprendre', + 'home.pick': 'À découvrir', + 'home.open': 'Voir le jeu', + 'home.recent': 'Derniers lancés', + 'home.favorites': 'Favoris', + 'home.added': 'Ajoutés récemment', + + 'lib.kicker': 'Tous vos jeux', + 'lib.collection': 'Collection', + 'lib.search': 'Rechercher', + 'lib.add': 'Ajouter un jeu', + 'lib.rescan': 'Rescanner', + 'lib.empty': 'Aucun jeu ici.', + 'lib.noResult': 'Aucun jeu pour « {q} ».', + 'lib.pickExe': 'Choisir l’exécutable du jeu', + + 'filter.all': 'Tous', + 'filter.installed': 'Installés', + 'filter.favorites': 'Favoris', + 'sort.name': 'Nom', + 'sort.playtime': 'Temps de jeu', + 'sort.recent': 'Dernier lancement', + 'sort.added': 'Date d’ajout', + + 'coll.kicker': 'Vos rangements', + 'coll.new': 'Nouvelle collection', + 'coll.none': 'Créez une collection, puis ajoutez-y des jeux depuis leur fiche.', + 'coll.count': '{n} jeux', + 'coll.empty': 'Collection vide : ajoutez des jeux depuis leur fiche.', + 'coll.options': 'Options', + 'coll.rename': 'Renommer', + 'coll.delete': 'Supprimer la collection', + + 'game.missing': 'Ce jeu n’existe plus.', + 'game.notInstalled': 'non installé', + 'game.playtime': 'Temps de jeu', + 'game.last': 'Dernière session', + 'game.release': 'Sortie', + 'game.dev': 'Studio', + 'game.play': 'Jouer', + 'game.running': 'En cours', + 'game.fav': 'Favori', + 'game.unfav': 'Retirer des favoris', + 'game.collections': 'Collections', + 'game.more': 'Plus', + 'game.edit': 'Modifier', + 'game.media': 'Changer les images', + 'game.refreshMedia': 'Retélécharger les images', + 'game.remove': 'Supprimer', + 'game.hide': 'Masquer', + 'game.removeQ': 'Supprimer ce jeu du launcher ?', + 'game.removeText': 'Le jeu reste sur le disque, seule sa fiche est supprimée.', + 'game.hideQ': 'Masquer ce jeu ?', + 'game.hideText': 'Il n’apparaîtra plus, même après un nouveau scan.', + 'game.playing': 'En jeu : {name}', + 'game.waiting': 'en attente du jeu', + 'game.launchError': 'Lancement impossible', + + 'edit.title': 'Modifier le jeu', + 'edit.addTitle': 'Ajouter un jeu', + 'edit.name': 'Nom', + 'edit.exe': 'Exécutable', + 'edit.args': 'Arguments', + 'edit.workDir': 'Dossier de travail', + 'edit.empty': 'Non renseigné', + + 'media.title': 'Images du jeu', + 'media.cover': 'Jaquette', + 'media.hero': 'Fond', + 'media.logo': 'Logo', + 'media.icon': 'Icône', + 'media.fromFile': 'Depuis un fichier', + 'media.loading': 'Chargement…', + 'media.noKey': 'Ajoutez une clé SteamGridDB dans les paramètres pour avoir plus de choix.', + 'media.failed': 'Téléchargement impossible.', + + 'fs.drives': 'Lecteurs', + 'fs.up': 'Dossier parent', + 'fs.pickDir': 'Choisir ce dossier', + 'fs.empty': 'Rien ici.', + 'fs.denied': 'Dossier inaccessible.', + + 'set.kicker': 'Réglages', + 'set.display': 'Affichage et sons', + 'set.theme': 'Thème', + 'set.language': 'Langue', + 'set.fullscreen': 'Plein écran', + 'set.volume': 'Volume des sons', + 'set.vibration': 'Vibration de la manette', + 'set.system': 'Système', + 'set.autostart': 'Lancer au démarrage de Windows', + 'set.kiosk': 'Mode kiosque', + 'set.kioskHint': 'Impossible de quitter sans le menu système', + 'set.kioskWarn': 'Le launcher reste en plein écran et ne peut plus être fermé au clavier. Pour remplacer complètement le bureau Windows, suivez le README.', + 'set.library': 'Bibliothèque', + 'set.sgdb': 'Clé API SteamGridDB', + 'set.sgdbHint': 'steamgriddb.com, profil, préférences, API', + 'set.none': 'Aucune', + 'set.folders': 'Dossiers de jeux', + 'set.addFolder': 'Ajouter un dossier de jeux', + 'set.removeFolder': 'Retirer', + 'set.about': 'À propos', + + 'sys.title': 'Menu', + 'sys.stopTracking': 'Revenir au launcher ({name})', + 'sys.stopHint': 'arrête le suivi du temps', + 'sys.quit': 'Quitter le launcher', + 'sys.quitQ': 'Quitter Orbe Launcher ?', + 'sys.sleep': 'Mettre en veille', + 'sys.sleepQ': 'Mettre le PC en veille ?', + 'sys.restart': 'Redémarrer', + 'sys.restartQ': 'Redémarrer le PC ?', + 'sys.shutdown': 'Éteindre', + 'sys.shutdownQ': 'Éteindre le PC ?' } diff --git a/src/renderer/src/nav/glyph-map.ts b/src/renderer/src/nav/glyph-map.ts index 8fff9ad..0aee011 100644 --- a/src/renderer/src/nav/glyph-map.ts +++ b/src/renderer/src/nav/glyph-map.ts @@ -1,9 +1,9 @@ /* Libellés des boutons selon la manette (pur, testé). */ import type { Action, InputKind } from './input' -const XBOX: Partial> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉' } -const PS: Partial> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE' } -const KEY: Partial> = { accept: 'Entrée', back: 'Échap', x: 'X', y: 'Y', lb: 'Q', rb: 'E', lt: 'PgPréc', rt: 'PgSuiv', menu: 'F1', view: 'Tab' } +const XBOX: Partial> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉', left: '◂▸' } +const PS: Partial> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE', left: '◂▸' } +const KEY: Partial> = { accept: 'Entrée', back: 'Échap', x: 'X', y: 'Y', lb: 'Q', rb: 'E', lt: 'PgPréc', rt: 'PgSuiv', menu: 'F1', view: 'Tab', left: '←→' } export function glyph(a: Action, kind: InputKind): string { return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a diff --git a/src/renderer/src/nav/nav.tsx b/src/renderer/src/nav/nav.tsx index 423ae06..46c8ca1 100644 --- a/src/renderer/src/nav/nav.tsx +++ b/src/renderer/src/nav/nav.tsx @@ -7,11 +7,13 @@ import { play } from './sounds' import { pickNext, type Dir } from './spatial' type Handler = (a: Action) => boolean | void // true = action consommée +type Entry = { current: Handler; prio: number; order: number } +let order = 0 interface Ctx { kind: InputKind focus: (el: Element | null) => void - push: (h: { current: Handler }) => () => void + push: (h: { current: Handler }, prio: number) => () => void sound: (s: SoundName) => void current: () => HTMLElement | null } @@ -29,7 +31,7 @@ const visible = (el: Element) => { const r = el.getBoundingClientRect(); return export function NavProvider({ children, onGuide }: { children: ReactNode; onGuide?: () => void }) { const [kind, setKind] = useState('mouse') const cur = useRef(null) - const stack = useRef<{ current: Handler }[]>([]) + const stack = useRef([]) // trié : priorité puis ordre d'arrivée const { settings } = useSettings() const vib = useRef(settings.vibration) vib.current = settings.vibration @@ -69,6 +71,7 @@ export function NavProvider({ children, onGuide }: { children: ReactNode; onGuid for (let i = stack.current.length - 1; i >= 0; i--) if (stack.current[i].current(a)) return if (a === 'up' || a === 'down' || a === 'left' || a === 'right') move(a) else if (a === 'accept' && cur.current) { play('select'); cur.current.click() } + else if (a === 'back') play('back') }, guideHeld: onGuide }) @@ -79,16 +82,23 @@ export function NavProvider({ children, onGuide }: { children: ReactNode; onGuid return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) } }, [move, focus, refocus, onGuide]) - const push = useCallback((h: { current: Handler }) => { stack.current.push(h); return () => { stack.current = stack.current.filter((x) => x !== h) } }, []) + const push = useCallback((h: { current: Handler }, prio: number) => { + const e: Entry = { get current() { return h.current }, prio, order: order++ } + stack.current = [...stack.current, e].sort((a, b) => a.prio - b.prio || a.order - b.order) + return () => { stack.current = stack.current.filter((x) => x !== e) } + }, []) return cur.current }}>{children} } export const useNavCtx = () => useContext(NavCtx) -/* Intercepte les actions tant que le composant est affiché (le plus récent passe en premier) */ -export function useNav(handler: Handler, active = true): void { +/* Intercepte les actions tant que le composant est affiché. + Priorité : 0 = coque de l'appli, 1 = écran, 2 = fenêtre modale ; à priorité égale, le plus récent passe en premier. */ +export function useNav(handler: Handler, active = true, prio = 1): void { const { push } = useNavCtx() const ref = useRef(handler) ref.current = handler - useEffect(() => (active ? push(ref) : undefined), [push, active]) + useEffect(() => (active ? push(ref, prio) : undefined), [push, active, prio]) } + +export const modalOpen = () => !!document.querySelector('[data-nav-scope]') diff --git a/src/renderer/src/screens/Collections.tsx b/src/renderer/src/screens/Collections.tsx new file mode 100644 index 0000000..dd83c51 --- /dev/null +++ b/src/renderer/src/screens/Collections.tsx @@ -0,0 +1,40 @@ +/* Collections créées par l'utilisateur */ +import { useState } from 'react' +import { api } from '../api' +import { Cover } from '../components/Cover' +import { Keyboard } from '../components/Keyboard' +import { useT } from '../i18n/i18n' +import { HintBar } from '../nav/glyphs' +import { useLibrary } from '../state/library' +import { useRouter } from '../state/router' + +export function Collections() { + const t = useT() + const { collections, games, reloadCollections } = useLibrary() + const { go } = useRouter() + const [creating, setCreating] = useState(false) + return ( +
+

{t('coll.kicker')}

+

{t('tab.collections')}

+
+ + {collections.map((c) => { + const inside = games.filter((g) => g.collections.includes(c.id)) + return ( + + ) + })} +
+ {!collections.length &&

{t('coll.none')}

} + + {creating && ( + setCreating(false)} onSubmit={async (v) => { if (v.trim()) { await api['collections:create'](v); await reloadCollections() } setCreating(false) }} /> + )} +
+ ) +} diff --git a/src/renderer/src/screens/GameDetail.tsx b/src/renderer/src/screens/GameDetail.tsx new file mode 100644 index 0000000..ff77bd2 --- /dev/null +++ b/src/renderer/src/screens/GameDetail.tsx @@ -0,0 +1,92 @@ +/* Fiche d'un jeu : fond « hero », logo, temps de jeu, dernière session, Jouer, favori, collections, modifier, jaquettes */ +import { useState } from 'react' +import { api } from '../api' +import { Confirm, Menu } from '../components/Modal' +import { Cover } from '../components/Cover' +import { CoverPicker } from '../components/CoverPicker' +import { EditGame } from '../components/EditGame' +import { Keyboard } from '../components/Keyboard' +import { useT } from '../i18n/i18n' +import { HintBar } from '../nav/glyphs' +import { useNav } from '../nav/nav' +import { useLibrary } from '../state/library' +import { useRouter } from '../state/router' +import { useSession } from '../state/session' +import { useSettings } from '../state/settings' +import { ago, playtime, PLATFORM_LABEL } from '../state/format' + +export function GameDetail({ id }: { id: string }) { + const t = useT() + const { byId, collections, reloadCollections } = useLibrary() + const { back } = useRouter() + const { settings } = useSettings() + const { launch, running } = useSession() + const [modal, setModal] = useState(null) + const g = byId.get(id) + useNav((a) => { + if (modal || !g) return false + if (a === 'y') { api['library:update'](g.id, { favorite: !g.favorite }); return true } + if (a === 'x') { setModal('more'); return true } + return false + }) + if (!g) return

{t('game.missing')}

+ const lang = settings.language + const isRunning = running?.gameId === g.id + const toggleColl = (cid: string) => api['library:update'](g.id, { collections: g.collections.includes(cid) ? g.collections.filter((c) => c !== cid) : [...g.collections, cid] }) + + return ( +
+
+
+

{PLATFORM_LABEL[g.platform]}{g.installed ? '' : ' · ' + t('game.notInstalled')}

+ {g.media.logo ? {g.name} :

{g.name}

} +
+
{t('game.playtime')}
{playtime(g.playtime, lang)}
+
{t('game.last')}
{ago(g.lastPlayed, lang)}
+ {g.info?.releaseDate &&
{t('game.release')}
{g.info.releaseDate}
} + {g.info?.developer &&
{t('game.dev')}
{g.info.developer}
} +
+
+ + + + +
+ {g.info?.genres &&

{g.info.genres.join(' · ')}

} + {g.info?.description &&

{g.info.description}

} +
+
+ + + {modal === 'more' && ( + setModal(null)} items={[ + { label: t('game.edit'), onSelect: () => setModal('edit') }, + { label: t('game.media'), onSelect: () => setModal('media') }, + { label: t('game.refreshMedia'), onSelect: () => { api['media:refresh'](g.id); setModal(null) } }, + { label: g.platform === 'manual' ? t('game.remove') : t('game.hide'), danger: true, onSelect: () => setModal('remove') } + ]} /> + )} + {modal === 'coll' && ( + setModal(null)} items={[ + ...collections.map((c) => ({ label: c.name, checked: g.collections.includes(c.id), onSelect: () => toggleColl(c.id) })), + { label: '+ ' + t('coll.new'), onSelect: () => setModal('newColl') } + ]} /> + )} + {modal === 'newColl' && ( + setModal('coll')} onSubmit={async (v) => { + if (v.trim()) { const c = await api['collections:create'](v); await reloadCollections(); await api['library:update'](g.id, { collections: [...g.collections, c.id] }) } + setModal('coll') + }} /> + )} + {modal === 'edit' && setModal(null)} />} + {modal === 'media' && setModal(null)} />} + {modal === 'remove' && ( + setModal(null)} onOk={async () => { await api['library:remove'](g.id); setModal(null); back() }} /> + )} +
+ ) +} diff --git a/src/renderer/src/screens/Home.tsx b/src/renderer/src/screens/Home.tsx new file mode 100644 index 0000000..3e323dc --- /dev/null +++ b/src/renderer/src/screens/Home.tsx @@ -0,0 +1,51 @@ +/* Accueil : dernier jeu en grand, puis derniers lancés, favoris, ajoutés récemment */ +import type { Game } from '@shared/types' +import { Cover } from '../components/Cover' +import { Row } from '../components/Row' +import { useT } from '../i18n/i18n' +import { HintBar } from '../nav/glyphs' +import { useLibrary } from '../state/library' +import { useRouter } from '../state/router' +import { useSettings } from '../state/settings' +import { ago, playtime } from '../state/format' + +export function Home() { + const t = useT() + const { games, scanning } = useLibrary() + const { go } = useRouter() + const { settings } = useSettings() + const open = (g: Game) => go({ name: 'game', id: g.id }) + const recent = games.filter((g) => g.lastPlayed).sort((a, b) => b.lastPlayed! - a.lastPlayed!).slice(0, 12) + const favs = games.filter((g) => g.favorite).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20) + const added = [...games].sort((a, b) => b.addedAt - a.addedAt).slice(0, 12) + const hero = recent[0] ?? favs[0] ?? added[0] + + if (!games.length) + return ( +
+

{t('home.kicker')}

+

{scanning ? t('home.scanning') : t('home.empty')}

+

{t('home.emptyText')}

+
+ ) + + return ( +
+ {hero && ( +
+
+
+

{recent[0] ? t('home.continue') : t('home.pick')}

+ {hero.media.logo ? {hero.name} :

{hero.name}

} +

{playtime(hero.playtime, settings.language)} · {ago(hero.lastPlayed, settings.language)}

+ +
+
+ )} + + + + +
+ ) +} diff --git a/src/renderer/src/screens/Library.tsx b/src/renderer/src/screens/Library.tsx new file mode 100644 index 0000000..ceba374 --- /dev/null +++ b/src/renderer/src/screens/Library.tsx @@ -0,0 +1,93 @@ +/* Bibliothèque (ou une collection) : filtres, tri, recherche instantanée, disposition choisie par le thème */ +import { useMemo, useState } from 'react' +import type { Game } from '@shared/types' +import { EditGame } from '../components/EditGame' +import { FileBrowser } from '../components/FileBrowser' +import { Grid } from '../components/Grid' +import { Keyboard } from '../components/Keyboard' +import { ListView } from '../components/ListView' +import { Menu } from '../components/Modal' +import { GameCard } from '../components/GameCard' +import { useT } from '../i18n/i18n' +import { HintBar } from '../nav/glyphs' +import { useNav } from '../nav/nav' +import { api } from '../api' +import { useLibrary } from '../state/library' +import { useRouter } from '../state/router' +import { PLATFORM_LABEL } from '../state/format' +import { useTheme } from '../theme/theme' +import { filterGames, SORTS, type Filter, type Sort } from './filter' + +export function Library({ collectionId }: { collectionId?: string }) { + const t = useT() + const { games, collections, rescan, scanning, reloadCollections } = useLibrary() + const { go, back, focusOnShow } = useRouter() + const { theme } = useTheme() + const [filter, setFilter] = useState('all') + const [sort, setSort] = useState('name') + const [query, setQuery] = useState('') + const [modal, setModal] = useState(null) + const [exe, setExe] = useState('') + const coll = collections.find((c) => c.id === collectionId) + + const base = collectionId ? games.filter((g) => g.collections.includes(collectionId)) : games + const platforms = useMemo(() => [...new Set(base.map((g) => g.platform))].sort(), [base]) + const list = useMemo(() => filterGames(base, { filter, sort, query }), [base, filter, sort, query]) + const open = (g: Game) => go({ name: 'game', id: g.id }) + + useNav((a) => { + if (modal) return false + if (a === 'x') { setModal('search'); return true } + if (a === 'y') { setSort(SORTS[(SORTS.indexOf(sort) + 1) % SORTS.length]); return true } + if (a === 'view' && collectionId) { setModal('collection'); return true } + return false + }) + + const filters: Filter[] = ['all', 'installed', 'favorites', ...platforms] + const layout = theme?.manifest.layout.library ?? 'grid' + return ( +
+
+
+

{coll ? t('lib.collection') : t('lib.kicker')}

+

{coll?.name ?? t('tab.library')} {list.length}

+
+
+ {query && } + + + {!collectionId && } + {!collectionId && } + {collectionId && } +
+
+
+ {filters.map((f) => ( + + ))} +
+ {!list.length &&

{query ? t('lib.noResult', { q: query }) : collectionId ? t('coll.empty') : t('lib.empty')}

} + {layout === 'grid' && } + {layout === 'list' && } + {layout === 'carousel' && ( +
{list.map((g, i) => )}
+ )} + + + {modal === 'search' && { setQuery(v); setModal(null) }} onCancel={() => setModal(null)} />} + {modal === 'add' && setModal(null)} onPick={(p) => { setExe(p); setModal('addForm') }} />} + {modal === 'addForm' && setModal(null)} onSaved={open} />} + {modal === 'rename' && coll && ( + setModal(null)} onSubmit={async (v) => { await api['collections:rename'](coll.id, v); await reloadCollections(); setModal(null) }} /> + )} + {modal === 'collection' && coll && ( + setModal(null)} items={[ + { label: t('coll.rename'), onSelect: () => setModal('rename') }, + { label: t('coll.delete'), danger: true, onSelect: async () => { await api['collections:delete'](coll.id); await reloadCollections(); setModal(null); back() } } + ]} /> + )} +
+ ) +} diff --git a/src/renderer/src/screens/Settings.tsx b/src/renderer/src/screens/Settings.tsx new file mode 100644 index 0000000..159d2ff --- /dev/null +++ b/src/renderer/src/screens/Settings.tsx @@ -0,0 +1,89 @@ +/* Paramètres : chaque ligne est focalisable ; gauche / droite change les valeurs à choix, A valide */ +import { useEffect, useState, type ReactNode } from 'react' +import type { AppInfo } from '@shared/types' +import { SCANNERS, type ScannerId } from '@shared/types' +import { api } from '../api' +import { FileBrowser } from '../components/FileBrowser' +import { Keyboard } from '../components/Keyboard' +import { Confirm } from '../components/Modal' +import { useT } from '../i18n/i18n' +import { HintBar } from '../nav/glyphs' +import { useNav, useNavCtx } from '../nav/nav' +import { useLibrary } from '../state/library' +import { useSettings } from '../state/settings' +import { useTheme } from '../theme/theme' +import { PLATFORM_LABEL } from '../state/format' + +function Line({ label, value, onSelect, onLeft, onRight, id, hint }: { label: string; value?: ReactNode; onSelect?: () => void; onLeft?: () => void; onRight?: () => void; id: string; hint?: string }) { + return ( + + ) +} + +export function Settings() { + const t = useT() + const { settings, update } = useSettings() + const { themes, theme } = useTheme() + const { rescan, scanning, lastScan } = useLibrary() + const { current } = useNavCtx() + const [info, setInfo] = useState() + const [modal, setModal] = useState(null) + useEffect(() => { api['app:info']().then(setInfo) }, []) + + // gauche / droite sur une ligne à choix : change la valeur au lieu de déplacer le focus + useNav((a) => { + if (modal || (a !== 'left' && a !== 'right')) return false + const el = current() as (HTMLElement & { __left?: () => void; __right?: () => void }) | null + const fn = a === 'left' ? el?.__left : el?.__right + if (!fn) return false + fn() + return true + }) + + const cycle = (list: T[], cur: T, d: number) => list[(list.indexOf(cur) + d + list.length) % list.length] + const themeIds = themes.map((x) => x.manifest.id) + const setTheme = (d: number) => update({ theme: cycle(themeIds, theme?.manifest.id ?? 'orbe', d) }) + const vol = (d: number) => update({ volume: Math.round(Math.min(1, Math.max(0, settings.volume + d * 0.1)) * 10) / 10 }) + const onOff = (b: boolean) => (b ? t('common.on') : t('common.off')) + const toggleScanner = (id: ScannerId) => update({ scanners: { ...settings.scanners, [id]: !settings.scanners[id] } }) + + return ( +
+

{t('set.kicker')}

+

{t('tab.settings')}

+
+
+

{t('set.display')}

+ setTheme(-1)} onRight={() => setTheme(1)} /> + update({ language: settings.language === 'fr' ? 'en' : 'fr' })} onRight={() => update({ language: settings.language === 'fr' ? 'en' : 'fr' })} /> + update({ fullscreen: !settings.fullscreen })} /> + } onLeft={() => vol(-1)} onRight={() => vol(1)} /> + update({ vibration: !settings.vibration })} /> +

{t('set.system')}

+ update({ autostart: !settings.autostart })} /> + (settings.kiosk ? update({ kiosk: false }) : setModal('kiosk'))} /> +
+
+

{t('set.library')}

+ setModal('key')} /> + {SCANNERS.map((id) => toggleScanner(id)} />)} + {settings.extraFolders.map((f) => update({ extraFolders: settings.extraFolders.filter((x) => x !== f) })} />)} + setModal('folder')} /> + +

{t('set.about')}

+

Orbe Launcher {info?.version} · {info?.dataDir}

+
+
+ + + {modal === 'key' && setModal(null)} onSubmit={(v) => { update({ steamGridDbKey: v.trim() }); setModal(null) }} />} + {modal === 'folder' && setModal(null)} onPick={(p) => { if (!settings.extraFolders.includes(p)) update({ extraFolders: [...settings.extraFolders, p] }); setModal(null) }} />} + {modal === 'kiosk' && setModal(null)} onOk={() => { update({ kiosk: true }); setModal(null) }} />} +
+ ) +} diff --git a/src/renderer/src/screens/filter.ts b/src/renderer/src/screens/filter.ts new file mode 100644 index 0000000..8773a9f --- /dev/null +++ b/src/renderer/src/screens/filter.ts @@ -0,0 +1,28 @@ +/* Filtre, tri et recherche de la bibliothèque (pur, testé) */ +import type { Game, Platform } from '@shared/types' + +export type Filter = 'all' | 'installed' | 'favorites' | Platform +export type Sort = 'name' | 'playtime' | 'recent' | 'added' +export const SORTS: Sort[] = ['name', 'playtime', 'recent', 'added'] + +const fold = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') + +export function filterGames(games: Game[], o: { filter: Filter; sort: Sort; query: string }): Game[] { + const q = fold(o.query.trim()) + const words = q ? q.split(/\s+/) : [] + const out = games.filter((g) => { + if (o.filter === 'installed' && !g.installed) return false + if (o.filter === 'favorites' && !g.favorite) return false + if (o.filter !== 'all' && o.filter !== 'installed' && o.filter !== 'favorites' && g.platform !== o.filter) return false + const name = fold(g.name) + return words.every((w) => name.includes(w)) // « witcher 3 » trouve « The Witcher 3: Wild Hunt » + }) + const byName = (a: Game, b: Game) => a.name.localeCompare(b.name, 'fr', { sensitivity: 'base', numeric: true }) + const cmp: Record number> = { + name: byName, + playtime: (a, b) => b.playtime - a.playtime || byName(a, b), + recent: (a, b) => (b.lastPlayed ?? 0) - (a.lastPlayed ?? 0) || byName(a, b), + added: (a, b) => b.addedAt - a.addedAt || byName(a, b) + } + return out.sort(cmp[o.sort]) +} diff --git a/src/renderer/src/state/format.ts b/src/renderer/src/state/format.ts new file mode 100644 index 0000000..fa9f30a --- /dev/null +++ b/src/renderer/src/state/format.ts @@ -0,0 +1,21 @@ +/* Mise en forme des durées et dates (pure, testée). */ +import { translate } from '../i18n/i18n' +import type { Language } from '@shared/types' + +export function playtime(sec: number, lang: Language): string { + if (sec < 60) return translate(lang, 'time.never') + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60) + return h ? translate(lang, 'time.hm', { h, m: String(m).padStart(2, '0') }) : translate(lang, 'time.m', { m }) +} + +export function ago(ts: number | undefined, lang: Language, now = Date.now()): string { + if (!ts) return translate(lang, 'time.never') + const d = Math.floor((now - ts) / 864e5) + if (now - ts < 3600e3) return translate(lang, 'time.justNow') + if (d < 1) return translate(lang, 'time.today') + if (d < 2) return translate(lang, 'time.yesterday') + if (d < 30) return translate(lang, 'time.days', { n: d }) + return new Date(ts).toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) +} + +export const PLATFORM_LABEL: Record = { steam: 'Steam', epic: 'Epic Games', gog: 'GOG', ubisoft: 'Ubisoft', ea: 'EA', folder: 'Dossier', manual: 'PC' } diff --git a/src/renderer/src/state/library.tsx b/src/renderer/src/state/library.tsx new file mode 100644 index 0000000..714ba3d --- /dev/null +++ b/src/renderer/src/state/library.tsx @@ -0,0 +1,40 @@ +/* Bibliothèque côté interface : jeux, collections, état du scan. */ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' +import type { ScanReport } from '@shared/ipc' +import type { Collection, Game } from '@shared/types' +import { api } from '../api' + +interface Ctx { + games: Game[] // visibles (non masqués) + all: Game[] + byId: Map + collections: Collection[] + scanning: boolean + lastScan?: ScanReport + rescan: () => void + reloadCollections: () => Promise +} + +const LibCtx = createContext(null!) + +export function LibraryProvider({ children }: { children: ReactNode }) { + const [all, setAll] = useState([]) + const [collections, setCollections] = useState([]) + const [scanning, setScanning] = useState(false) + const [lastScan, setLastScan] = useState() + const reloadCollections = useCallback(async () => setCollections([...(await api['collections:list']())]), []) + useEffect(() => { + api['library:list']().then((g) => setAll([...g])) + reloadCollections() + const a = api.on('library:changed', (g) => setAll([...g])) + const b = api.on('scan:state', (s) => { setScanning(s.running); if (s.report) setLastScan(s.report) }) + return () => { a(); b() } + }, [reloadCollections]) + const value = useMemo(() => { + const games = all.filter((g) => !g.hidden) + return { games, all, byId: new Map(all.map((g) => [g.id, g])), collections, scanning, lastScan, rescan: () => { api['library:rescan']() }, reloadCollections } + }, [all, collections, scanning, lastScan, reloadCollections]) + return {children} +} + +export const useLibrary = () => useContext(LibCtx) diff --git a/src/renderer/src/state/router.tsx b/src/renderer/src/state/router.tsx new file mode 100644 index 0000000..4bbf3bb --- /dev/null +++ b/src/renderer/src/state/router.tsx @@ -0,0 +1,42 @@ +/* Navigation entre écrans : pile de routes, onglets à la racine, focus restauré au retour. */ +import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' + +export type Tab = 'home' | 'library' | 'collections' | 'settings' +export type Route = { name: Tab } | { name: 'collection'; id: string } | { name: 'game'; id: string } +interface Entry { route: Route; focus?: string } // focus = data-nav-id à retrouver au retour + +interface Ctx { + route: Route + focusOnShow?: string + tab: Tab + go: (r: Route) => void // empile + back: () => boolean // false si déjà à la racine + setTab: (t: Tab) => void +} + +const RouterCtx = createContext(null!) +export const TABS: Tab[] = ['home', 'library', 'collections', 'settings'] + +const currentNavId = () => (document.querySelector('.is-focused') as HTMLElement | null)?.dataset.navId + +export function RouterProvider({ children }: { children: ReactNode }) { + const [stack, setStack] = useState([{ route: { name: 'home' } }]) + const [focusOnShow, setFocusOnShow] = useState() + const go = useCallback((r: Route) => { + const f = currentNavId() + setFocusOnShow(undefined) + setStack((s) => [...s.slice(0, -1), { ...s[s.length - 1], focus: f }, { route: r }]) + }, []) + const back = useCallback(() => { + if (stack.length < 2) return false + setFocusOnShow(stack[stack.length - 2].focus) + setStack((s) => s.slice(0, -1)) + return true + }, [stack]) + const setTab = useCallback((t: Tab) => { setFocusOnShow(undefined); setStack([{ route: { name: t } }]) }, []) + const route = stack[stack.length - 1].route + const tab = (TABS.includes(stack[0].route.name as Tab) ? stack[0].route.name : 'home') as Tab + return {children} +} + +export const useRouter = () => useContext(RouterCtx) diff --git a/src/renderer/src/state/session.tsx b/src/renderer/src/state/session.tsx new file mode 100644 index 0000000..7258c7e --- /dev/null +++ b/src/renderer/src/state/session.tsx @@ -0,0 +1,46 @@ +/* Jeu en cours : lancement, mise en pause de l'interface pendant le jeu, son de lancement */ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' +import type { RunningGame } from '@shared/ipc' +import type { Game } from '@shared/types' +import { api } from '../api' +import { play } from '../nav/sounds' + +interface Ctx { + running: RunningGame | null + error: string + launch: (g: Game) => Promise + stop: () => void + clearError: () => void +} + +const SessionCtx = createContext(null!) + +export function SessionProvider({ children }: { children: ReactNode }) { + const [running, setRunning] = useState(null) + const [error, setError] = useState('') + const [focused, setFocused] = useState(true) + useEffect(() => { + api['session:get']().then(setRunning) + const off = api.on('session:state', (s) => setRunning(s.running)) + const f = () => setFocused(true), b = () => setFocused(false) + addEventListener('focus', f) + addEventListener('blur', b) + return () => { off(); removeEventListener('focus', f); removeEventListener('blur', b) } + }, []) + // pendant un jeu, et tant que le launcher n'est pas au premier plan : animations, vidéo et orbe en pause + useEffect(() => { + const root = document.documentElement + if (running && !focused) root.dataset.paused = '' + else delete root.dataset.paused + }, [running, focused]) + const launch = useCallback(async (g: Game) => { + play('launch') + try { await api['game:launch'](g.id) } catch (e) { + play('error') + setError(String((e as Error).message ?? e).replace(/^Error invoking remote method '[^']+': (Error: )?/, '')) + } + }, []) + return api['game:stop'](), clearError: () => setError('') }}>{children} +} + +export const useSession = () => useContext(SessionCtx) diff --git a/src/renderer/src/styles/base.css b/src/renderer/src/styles/base.css index 022b712..fc06a38 100644 --- a/src/renderer/src/styles/base.css +++ b/src/renderer/src/styles/base.css @@ -45,7 +45,8 @@ mark { background: none; color: var(--accent); } .topbar { position: fixed; inset: 0 0 auto 0; height: 76px; display: flex; align-items: center; gap: 28px; padding: 0 var(--pad); z-index: 20; } /* Fond animé */ -.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; } +.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; transition: opacity var(--dur-slow); } +:root:not([data-route='home']) .bg-layer { opacity: .25; } /* fond discret hors de l'accueil */ .bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; } /* Barre d'aide (boutons) */ @@ -77,3 +78,154 @@ mark { background: none; color: var(--accent); } .osk__key.is-on { color: var(--accent); } .osk__key.is-focused { box-shadow: var(--focus-ring); transform: scale(1.06); background: var(--surface-2); } .osk__key--ok.is-focused { background: var(--accent); } + +/* ── Coque ── */ +.topbar__logo { font: 900 26px/1 var(--font-sans); letter-spacing: -.04em; } +.topbar__logo span, .splash__logo span { color: var(--accent); } +.tabs { display: flex; align-items: center; gap: 6px; margin: 0 auto; } +.tab { padding: 10px 18px; border-radius: var(--radius-s); font: 400 14px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--muted); transition: color var(--dur-fast), background var(--dur-fast); } +.tab.is-active { color: var(--text); background: var(--surface-2); box-shadow: inset 0 -2px 0 var(--accent); } +.topbar__end { display: flex; gap: 18px; font-size: 15px; letter-spacing: .08em; } +.view { position: fixed; inset: 76px 0 0 0; animation: view-in var(--dur-med) var(--ease); } +@keyframes view-in { from { opacity: 0; transform: translateY(10px); } } +.screen { height: 100%; padding: 36px var(--pad) 90px; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; } +.screen::-webkit-scrollbar { display: none; } +.playing { position: fixed; left: var(--pad); bottom: 18px; z-index: 31; font-size: 14px; color: var(--accent); letter-spacing: .06em; } +.small { font-size: 14px; } +.error { color: var(--danger); } +.actions { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 28px; } + +/* ── Cartes : nom et favori ── */ +.card { padding: 0; width: 100%; } +.card__name { position: absolute; inset: auto 0 0 0; padding: 30px 12px 10px; font: 600 14px/1.25 var(--font-sans); text-align: left; color: var(--text); + background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 92%, transparent)); opacity: 0; transition: opacity var(--dur-fast); } +.card.is-focused .card__name { opacity: 1; } +.card__fav { position: absolute; top: 8px; right: 10px; color: var(--accent); font-size: 18px; text-shadow: 0 1px 6px rgba(0, 0, 0, .6); } +.card--off img, .card--off .placeholder { filter: grayscale(1); opacity: .45; } + +/* Jaquette générée (pas d'image) */ +.placeholder { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; padding: 18px; text-align: left; + background: radial-gradient(circle at 70% 20%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%), linear-gradient(160deg, var(--surface-2), var(--surface)); } +.placeholder__p { font: 400 11px/1 var(--font-mono); letter-spacing: .18em; text-transform: uppercase; color: var(--accent); } +.placeholder__n { font: 900 22px/1 var(--font-sans); letter-spacing: -.03em; text-transform: uppercase; overflow-wrap: anywhere; } +.placeholder--hero { justify-content: center; align-items: flex-end; opacity: .5; } + +/* ── Accueil ── */ +.home { padding-top: 0; } +.home__hero { position: relative; height: 52vh; min-height: 360px; margin: 0 calc(-1 * var(--pad)) 10px; display: flex; align-items: flex-end; padding: 0 var(--pad) 42px; } +.home__heroBg, .detail__bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; } +.home__heroBg img, .detail__bg img { width: 100%; height: 100%; object-fit: cover; } +.home__heroBg::after, .detail__bg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, var(--bg) 8%, color-mix(in srgb, var(--bg) 40%, transparent) 60%, transparent), linear-gradient(transparent 40%, var(--bg)); } +.home__heroTxt { max-width: 720px; display: flex; flex-direction: column; align-items: flex-start; gap: 14px; } +.home__heroTxt .hero-title { font-size: clamp(40px, 5vw, 84px); } +.home__logo, .detail__logo { max-width: min(520px, 80%); max-height: 170px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 6px 24px rgba(0, 0, 0, .6)); } +.row { margin-top: 26px; } +.row__title { margin-bottom: 14px; } +.row__track { display: flex; gap: 22px; overflow-x: auto; overflow-y: visible; padding: 14px 4px 22px; margin: -14px -4px 0; scrollbar-width: none; } +.row__track::-webkit-scrollbar { display: none; } +.row__track .card { flex: none; width: 190px; } +.screen--empty { display: flex; flex-direction: column; justify-content: center; max-width: 900px; } + +/* ── Bibliothèque ── */ +.library { display: flex; flex-direction: column; overflow: hidden; padding-bottom: 70px; } +.library__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap; } +.count { font: 400 22px/1 var(--font-mono); letter-spacing: 0; vertical-align: super; } +.toolbar { display: flex; gap: 10px; flex-wrap: wrap; } +.toolbar .btn { padding: 11px 16px; } +.chips { display: flex; gap: 8px; margin: 22px 0 18px; flex-wrap: wrap; } +.chip { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line); font: 400 13px/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); + transition: color var(--dur-fast), border-color var(--dur-fast), box-shadow var(--dur-fast); } +.chip--on { color: var(--on-accent); background: var(--accent); border-color: var(--accent); } +.chip.is-focused { box-shadow: var(--focus-ring); color: var(--text); } +.chip--on.is-focused { color: var(--on-accent); } +.empty { margin-top: 40px; } +.grid { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 14px 14px 40px; margin: -14px -14px 0; scrollbar-width: none; } +.grid::-webkit-scrollbar { display: none; } +.grid__inner { position: relative; } +.grid__cell { position: absolute; top: 0; left: 0; } +.list { flex: 1; overflow-y: auto; scrollbar-width: none; } +.row-item { display: grid; grid-template-columns: 1fr 140px 140px 170px; gap: 18px; align-items: center; width: 100%; padding: 18px 6px; text-align: left; font-size: 20px; } +.row-item__meta { font-size: 13px; color: var(--muted); } +.carousel { display: flex; gap: 30px; align-items: center; overflow-x: auto; flex: 1; padding: 0 30vw; scrollbar-width: none; } +.carousel .card { flex: none; width: 260px; } + +/* ── Collections ── */ +.colls { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 22px; margin-top: 28px; } +.coll { display: flex; flex-direction: column; gap: 8px; padding: 16px; text-align: left; border-radius: var(--radius-m); background: var(--surface); border: 1px solid var(--line); + transition: transform var(--dur-med) var(--ease), box-shadow var(--dur-med), border-color var(--dur-med); } +.coll.is-focused { transform: scale(1.03); box-shadow: var(--focus-ring); border-color: var(--accent); } +.coll--new { justify-content: center; align-items: center; min-height: 200px; border-style: dashed; color: var(--muted); font: 400 15px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; } +.coll__covers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; height: 150px; } +.coll__covers > * { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-s); overflow: hidden; } +.coll__covers .placeholder { padding: 8px; } +.coll__covers .placeholder__n { font-size: 12px; } +.coll__name { font: 800 22px/1.1 var(--font-sans); } + +/* ── Fiche jeu ── */ +.detail { display: grid; grid-template-columns: 1fr 300px; gap: 60px; align-items: end; padding-bottom: 110px; } +.detail__bg { position: fixed; top: 76px; } +.detail__bg img { opacity: .9; } +.detail__body { max-width: 860px; display: flex; flex-direction: column; align-items: flex-start; } +.detail__name { font-size: clamp(44px, 6vw, 100px); } +.detail__cover { width: 300px; aspect-ratio: 2 / 3; box-shadow: var(--shadow-card); } +.stats { display: flex; gap: 40px; margin: 22px 0 0; } +.stats dt { font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; } +.stats dd { margin: 0; font: 700 22px/1 var(--font-sans); } +.btn--play { font-size: 15px; padding: 16px 30px; } +.genres { margin: 22px 0 6px; font-size: 13px; letter-spacing: .1em; } +.detail__desc { max-width: 720px; color: color-mix(in srgb, var(--text) 82%, var(--muted)); font-size: 17px; } + +/* ── Paramètres ── */ +.settings__cols { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; margin-top: 18px; } +.settings__h { font-size: 12px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); margin: 26px 0 8px; font-weight: 400; } +.setting { display: flex; justify-content: space-between; align-items: center; gap: 20px; width: 100%; padding: 15px 16px; border-radius: var(--radius-s); text-align: left; + transition: background var(--dur-fast), box-shadow var(--dur-fast); } +.setting__l { display: flex; flex-direction: column; gap: 3px; } +.setting__l small { font-size: 13px; } +.setting__v { color: var(--muted); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 55%; } +.setting.is-focused { background: var(--surface-2); box-shadow: var(--focus-ring); } +.setting.is-focused .setting__v { color: var(--accent); } +.bar { display: inline-block; width: 140px; height: 6px; border-radius: 3px; background: var(--line); vertical-align: middle; overflow: hidden; } +.bar span { display: block; height: 100%; background: var(--accent); } + +/* ── Fenêtres ── */ +.modal__box { width: min(640px, 92vw); max-height: 80vh; overflow-y: auto; padding: 34px; border-radius: var(--radius-l); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-card); scrollbar-width: none; } +.modal__box--wide { width: min(1100px, 94vw); } +.modal__title { font-size: 34px; margin-bottom: 22px; } +.menu { display: flex; flex-direction: column; gap: 4px; } +.menu__item { display: flex; align-items: center; gap: 14px; padding: 15px 16px; border-radius: var(--radius-s); text-align: left; font-size: 19px; transition: background var(--dur-fast), padding-left var(--dur-med) var(--ease); } +.menu__item.is-focused { background: var(--surface-2); box-shadow: var(--focus-ring); padding-left: 22px; } +.menu__item--danger.is-focused { color: var(--danger); box-shadow: 0 0 0 2px var(--danger); } +.menu__check { color: var(--accent); font-size: 14px; } +.menu__hint { margin-left: auto; font-size: 12px; color: var(--muted); } +.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 6px; } +.file { padding: 12px 14px; border-radius: var(--radius-s); text-align: left; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: var(--bg); border: 1px solid transparent; } +.file--dir { color: var(--text); } +.file--up, .file--pick { color: var(--accent); } +.file.is-focused { border-color: var(--accent); box-shadow: var(--focus-ring); } +.form { display: flex; flex-direction: column; gap: 8px; } +.field { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; padding: 15px 16px; border-radius: var(--radius-s); background: var(--bg); text-align: left; border: 1px solid var(--line); } +.field__l { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); } +.field__v { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.field.is-focused { border-color: var(--accent); box-shadow: var(--focus-ring); } +.seg { display: flex; gap: 6px; margin-bottom: 18px; } +.seg__b { padding: 9px 16px; border-radius: var(--radius-s); font: 400 13px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line); } +.seg__b.is-active { color: var(--on-accent); background: var(--accent); border-color: var(--accent); } +.picker { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; } +.picker--hero, .picker--logo { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } +.picker__opt { border-radius: var(--radius-s); overflow: hidden; background: var(--bg); aspect-ratio: 2 / 3; border: 1px solid var(--line); transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast); } +.picker--hero .picker__opt { aspect-ratio: 96 / 31; } +.picker--logo .picker__opt, .picker--icon .picker__opt { aspect-ratio: 16 / 9; padding: 12px; } +.picker--icon .picker__opt { aspect-ratio: 1; } +.picker__opt img { width: 100%; height: 100%; object-fit: cover; } +.picker--logo img, .picker--icon img { object-fit: contain; } +.picker__file { display: grid; place-items: center; color: var(--accent); font: 400 13px/1.3 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; padding: 12px; } +.picker__opt.is-focused { transform: scale(1.05); box-shadow: var(--focus-ring); } + +/* ── Démarrage ── */ +.splash { position: fixed; inset: 0; z-index: 100; background: var(--bg); display: grid; place-items: center; transition: opacity .45s var(--ease); } +.splash canvas, .splash img, .splash video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.splash__logo { position: relative; font: 900 clamp(60px, 9vw, 150px)/1 var(--font-sans); letter-spacing: -.05em; animation: splash-in 1.1s var(--ease) both; } +.splash.is-out { opacity: 0; } +@keyframes splash-in { from { opacity: 0; transform: scale(.94); letter-spacing: .02em; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; } } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a82da4c..7965466 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -22,6 +22,9 @@ export interface FsEntry { name: string; path: string; dir: boolean } export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] } export type FsFilter = 'exe' | 'image' | 'dir' +export interface RunningGame { gameId: string; start: number; tracked: boolean } +export type PowerAction = 'sleep' | 'restart' | 'shutdown' + export type MediaKind = 'cover' | 'hero' | 'logo' | 'icon' export interface MediaOption { url: string; thumb: string; width?: number; height?: number } @@ -44,6 +47,11 @@ export interface Api { 'media:set': (gameId: string, kind: MediaKind, url: string) => Promise 'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise 'media:refresh': (gameId: string) => Promise + 'game:launch': (id: string) => Promise + 'game:stop': () => Promise + 'session:get': () => Promise + 'system:power': (action: PowerAction) => Promise + 'system:quit': () => Promise 'collections:list': () => Promise 'collections:create': (name: string) => Promise 'collections:rename': (id: string, name: string) => Promise @@ -57,6 +65,8 @@ export interface Api { export interface Events { 'library:changed': Game[] 'scan:state': { running: boolean; report?: ScanReport } + 'session:state': { running: RunningGame | null; last?: { gameId: string; duration: number } } + 'app:front': true 'settings:changed': Settings 'themes:changed': { id: string; themes: ThemeEntry[] } } @@ -76,6 +86,11 @@ export const CHANNELS = [ 'media:set', 'media:setFile', 'media:refresh', + 'game:launch', + 'game:stop', + 'session:get', + 'system:power', + 'system:quit', 'collections:list', 'collections:create', 'collections:rename', @@ -85,7 +100,7 @@ export const CHANNELS = [ 'themes:list' ] as const satisfies readonly Channel[] -export const EVENTS = ['library:changed', 'scan:state', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] +export const EVENTS = ['library:changed', 'scan:state', 'session:state', 'app:front', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] /* Ce que l'interface voit dans window.orbe */ export type OrbeApi = Api & { diff --git a/src/shared/types.ts b/src/shared/types.ts index eb77c0d..88fc230 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,8 +1,8 @@ /* Types partagés entre le processus principal et l'interface */ -export type Platform = 'steam' | 'epic' | 'gog' | 'ubisoft' | 'ea' | 'manual' +export type Platform = 'steam' | 'epic' | 'gog' | 'ubisoft' | 'ea' | 'folder' | 'manual' export type ScannerId = Exclude -export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea'] +export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea', 'folder'] export interface GameMedia { cover?: string // URL servie par orbe-media:// (cache) ou fichier choisi par l'utilisateur @@ -63,7 +63,7 @@ export interface Settings { theme: string steamGridDbKey: string scanners: Record - extraFolders: string[] // dossiers supplémentaires où chercher des exe (ajout rapide) + extraFolders: string[] // dossiers de jeux : chaque sous-dossier = un jeu (scanner « folder ») autostart: boolean fullscreen: boolean kiosk: boolean @@ -75,7 +75,7 @@ export interface Settings { export const DEFAULT_SETTINGS: Settings = { theme: 'orbe', steamGridDbKey: '', - scanners: { steam: true, epic: true, gog: true, ubisoft: true, ea: true }, + scanners: { steam: true, epic: true, gog: true, ubisoft: true, ea: true, folder: true }, extraFolders: [], autostart: false, fullscreen: true, diff --git a/tests/library-ui.test.ts b/tests/library-ui.test.ts new file mode 100644 index 0000000..ff5bb1b --- /dev/null +++ b/tests/library-ui.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { filterGames } from '../src/renderer/src/screens/filter' +import type { Game } from '../src/shared/types' + +const g = (name: string, o: Partial = {}): Game => ({ id: name, platform: 'steam', platformId: name, name, installed: true, favorite: false, hidden: false, addedAt: 0, playtime: 0, collections: [], media: {}, ...o }) +const games = [g('The Witcher 3: Wild Hunt', { platform: 'gog', playtime: 50 }), g('Hades', { favorite: true, lastPlayed: 10, addedAt: 5 }), g('Pokémon Légendes', { installed: false, playtime: 90 }), g('Celeste', { lastPlayed: 20 })] +const names = (o: Parameters[1]) => filterGames(games, o).map((x) => x.name) + +describe('filtres de la bibliothèque', () => { + it('cherche par mots, sans accents ni casse', () => { + expect(names({ filter: 'all', sort: 'name', query: 'witcher 3' })).toEqual(['The Witcher 3: Wild Hunt']) + expect(names({ filter: 'all', sort: 'name', query: 'pokemon legendes' })).toEqual(['Pokémon Légendes']) + }) + it('filtre installés, favoris, plateforme', () => { + expect(names({ filter: 'installed', sort: 'name', query: '' })).not.toContain('Pokémon Légendes') + expect(names({ filter: 'favorites', sort: 'name', query: '' })).toEqual(['Hades']) + expect(names({ filter: 'gog', sort: 'name', query: '' })).toEqual(['The Witcher 3: Wild Hunt']) + }) + it('trie', () => { + expect(names({ filter: 'all', sort: 'name', query: '' })).toEqual(['Celeste', 'Hades', 'Pokémon Légendes', 'The Witcher 3: Wild Hunt']) + expect(names({ filter: 'all', sort: 'playtime', query: '' })[0]).toBe('Pokémon Légendes') + expect(names({ filter: 'all', sort: 'recent', query: '' }).slice(0, 2)).toEqual(['Celeste', 'Hades']) + }) +}) diff --git a/tests/match.test.ts b/tests/match.test.ts new file mode 100644 index 0000000..6dd0b95 --- /dev/null +++ b/tests/match.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { findGameProcs } from '../src/main/launch/match' +import { splitArgs } from '../src/main/launch/session' + +const procs = [ + { pid: 1, path: 'C:\\Program Files (x86)\\Steam\\steam.exe' }, + { pid: 2, path: 'D:\\SteamLibrary\\steamapps\\common\\Hollow Knight\\hollow_knight.exe' }, + { pid: 3, path: 'D:\\SteamLibrary\\steamapps\\common\\Hollow Knight\\UnityCrashHandler64.exe' }, + { pid: 4, path: 'D:\\SteamLibrary\\steamapps\\common\\Hollow Knight 2\\hk2.exe' } +] + +describe('processus du jeu', () => { + it('trouve l’exe dans le dossier, sans les outils ni un dossier voisin', () => { + expect(findGameProcs(procs, { installDir: 'D:\\SteamLibrary\\steamapps\\common\\Hollow Knight' }).map((p) => p.pid)).toEqual([2]) + expect(findGameProcs(procs, { installDir: 'd:/steamlibrary/steamapps/common/hollow knight/' }).map((p) => p.pid)).toEqual([2]) + }) + it("reconnaît l'exe exact même hors du dossier", () => { + expect(findGameProcs(procs, { exe: 'C:\\Program Files (x86)\\Steam\\steam.exe' }).map((p) => p.pid)).toEqual([1]) + }) + it('ignore le launcher lui-même', () => { + expect(findGameProcs(procs, { installDir: 'C:\\Program Files (x86)\\Steam', selfPid: 1 })).toEqual([]) + }) +}) + +describe('arguments', () => { + it('respecte les guillemets', () => { + expect(splitArgs('-windowed "C:\\Mon Dossier\\cfg.ini" -w 1920')).toEqual(['-windowed', 'C:\\Mon Dossier\\cfg.ini', '-w', '1920']) + expect(splitArgs(undefined)).toEqual([]) + }) +}) diff --git a/tests/scanners.test.ts b/tests/scanners.test.ts index 6fb74c1..bf6b40d 100644 --- a/tests/scanners.test.ts +++ b/tests/scanners.test.ts @@ -97,3 +97,19 @@ describe('runScanners', () => { expect(r.ok.gog).toBeUndefined() }) }) + +import { mainExe } from '../src/main/scanners/folder' + +describe('dossiers de jeux', () => { + it("prend le plus gros exe qui n'est pas un outil", () => { + const tree: Record = { + 'D:\\Jeux\\Celeste': ['Celeste.exe', 'unins000.exe', 'Content'], + 'D:\\Jeux\\Celeste\\Content': ['Big.exe', 'Crash_Reporter.exe'], + 'D:\\Jeux\\Vide': ['readme.txt'] + } + const env = { list: (d: string) => tree[d] ?? [] } as unknown as ScanEnv + const sizes: Record = { 'D:\\Jeux\\Celeste\\Celeste.exe': 50, 'D:\\Jeux\\Celeste\\unins000.exe': 900, 'D:\\Jeux\\Celeste\\Content\\Big.exe': 60, 'D:\\Jeux\\Celeste\\Content\\Crash_Reporter.exe': 999 } + expect(mainExe(env, 'D:\\Jeux\\Celeste', (p) => sizes[p] ?? 0)).toBe('D:\\Jeux\\Celeste\\Content\\Big.exe') + expect(mainExe(env, 'D:\\Jeux\\Vide', () => 0)).toBeNull() + }) +})