Écrans : accueil, bibliothèque (grille virtualisée, filtres, tri, recherche), collections, fiche jeu, ajout manuel, choix des jaquettes
Some checks are pending
CI / windows (push) Waiting to run

This commit is contained in:
yasder5 2026-10-11 16:12:44 +02:00
parent baa9f7391c
commit 1cfe89d1fd
43 changed files with 1890 additions and 45 deletions

View file

@ -7,6 +7,10 @@ import { initLog, log } from './log'
import { Media } from './media' import { Media } from './media'
import { MediaCache } from './media/cache' import { MediaCache } from './media/cache'
import { registerMedia } from './media-ipc' 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 { Store } from './store'
import { registerThemeScheme, Themes } from './themes' import { registerThemeScheme, Themes } from './themes'
@ -54,12 +58,19 @@ function createWindow(): BrowserWindow {
return w return w
} }
app.on('second-instance', () => { /* Ramène le launcher devant le jeu (Windows refuse souvent un simple focus() : passage temporaire au premier plan absolu) */
if (win) { function toFront(): void {
if (!win) return
if (win.isMinimized()) win.restore() if (win.isMinimized()) win.restore()
win.show()
win.setAlwaysOnTop(true, 'screen-saver')
win.focus() win.focus()
app.focus({ steal: true })
setTimeout(() => win?.setAlwaysOnTop(false), 400)
emit(win, 'app:front', true)
} }
})
app.on('second-instance', toFront)
app.whenReady().then(() => { app.whenReady().then(() => {
store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined) store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined)
@ -70,6 +81,9 @@ app.whenReady().then(() => {
onSettings: (s: typeof store.settings, before: typeof store.settings) => { onSettings: (s: typeof store.settings, before: typeof store.settings) => {
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
if (s.steamGridDbKey !== before.steamGridDbKey) { media.keyChanged(); media.enqueue(media.missing()) } 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')) const cache = new MediaCache(join(dataDir, 'media'))
@ -78,6 +92,23 @@ app.whenReady().then(() => {
const media = new Media(ctx, cache) const media = new Media(ctx, cache)
registerMedia(ctx, media) registerMedia(ctx, media)
const library = registerLibrary(ctx, (ids) => media.enqueue(ids)) 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() themes.registerProtocol()
handle('themes:list', async () => themes.list()) handle('themes:list', async () => themes.list())
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))

18
src/main/launch/match.ts Normal file
View file

@ -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)
})
}

101
src/main/launch/session.ts Normal file
View file

@ -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<void> {
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<void>((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 }
}

View file

@ -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
}
}
}

View file

@ -4,6 +4,7 @@ import type { ScannerId, Settings } from '../../shared/types'
import { log } from '../log' import { log } from '../log'
import { ea } from './ea' import { ea } from './ea'
import { epic } from './epic' import { epic } from './epic'
import { folderScanner } from './folder'
import { gog } from './gog' import { gog } from './gog'
import { regQuery } from './reg' import { regQuery } from './reg'
import { steam } from './steam' import { steam } from './steam'
@ -28,7 +29,7 @@ export interface ScanResult {
const withTimeout = <T>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`délai dépassé (${ms} ms)`)), ms))]) const withTimeout = <T>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<T>((_, 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<ScanResult> { export async function runScanners(settings: Settings, env: ScanEnv = realEnv, scanners = [...ALL_SCANNERS, folderScanner(() => settings.extraFolders)]): Promise<ScanResult> {
const res: ScanResult = { ok: {}, failed: [] } const res: ScanResult = { ok: {}, failed: [] }
await Promise.all( await Promise.all(
scanners.filter((s) => settings.scanners[s.id]).map(async (s) => { scanners.filter((s) => settings.scanners[s.id]).map(async (s) => {

13
src/main/win/power.ts Normal file
View file

@ -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'])
}

55
src/main/win/procs.ts Normal file
View file

@ -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
}

41
src/main/win/xinput.ts Normal file
View file

@ -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)
}

View file

@ -1,28 +1,75 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { Background } from './components/Background' import { Background } from './components/Background'
import { Keyboard } from './components/Keyboard' import { Modal } from './components/Modal'
import { HintBar } from './nav/glyphs' import { Splash } from './components/Splash'
import { NavProvider } from './nav/nav' 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 { SettingsProvider } from './state/settings'
import { ThemeProvider } from './theme/theme' import { ThemeProvider } from './theme/theme'
import './styles/base.css' import './styles/base.css'
function Preview() { function Shell() {
const [kb, setKb] = useState(false) const t = useT()
const [text, setText] = useState('') 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 ( return (
<main style={{ padding: 'var(--pad)' }}> <>
<Background paused={false} /> <Background paused={!!running} />
<p className="kicker">Navigation</p> <TopBar />
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1> <div className="view" key={JSON.stringify(route)}>
<p className="muted">{text || '…'}</p> {route.name === 'home' && <Home />}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 140px)', gap: 18, marginTop: 32 }}> {route.name === 'library' && <Library />}
{Array.from({ length: 12 }, (_, i) => <button key={i} className="card" data-nav style={{ height: 210 }} />)} {route.name === 'collections' && <Collections />}
{route.name === 'collection' && <Library collectionId={route.id} />}
{route.name === 'settings' && <Settings />}
{route.name === 'game' && <GameDetail id={route.id} />}
</div> </div>
<button className="btn btn--primary" data-nav style={{ marginTop: 32 }} onClick={() => setKb(true)}>Clavier</button> {g && <div className="playing mono">▶ {t('game.playing', { name: g.name })}{!running.tracked && ' · ' + t('game.waiting')}</div>}
{kb && <Keyboard value={text} onSubmit={(v) => { setText(v); setKb(false) }} onCancel={() => setKb(false)} />} {system && <SystemMenu onClose={() => setSystem(false)} />}
{!kb && <HintBar items={[['accept', 'hint.select'], ['back', 'hint.back']]} />} {error && (
</main> <Modal title={t('game.launchError')} onClose={clearError}>
<p className="muted">{error}</p>
<div className="actions"><button className="btn btn--primary" data-nav data-nav-autofocus onClick={clearError}>OK</button></div>
</Modal>
)}
{splash && <Splash onDone={endSplash} />}
</>
) )
} }
@ -31,7 +78,13 @@ export function App() {
<SettingsProvider> <SettingsProvider>
<ThemeProvider> <ThemeProvider>
<NavProvider> <NavProvider>
<Preview /> <LibraryProvider>
<SessionProvider>
<RouterProvider>
<Shell />
</RouterProvider>
</SessionProvider>
</LibraryProvider>
</NavProvider> </NavProvider>
</ThemeProvider> </ThemeProvider>
</SettingsProvider> </SettingsProvider>

View file

@ -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<string>()
const src = game.media[kind]
if (src && src !== broken) return <img src={src} alt="" loading="lazy" decoding="async" onError={() => setBroken(src)} />
return (
<div className={'placeholder placeholder--' + kind}>
<span className="placeholder__p">{PLATFORM_LABEL[game.platform]}</span>
<span className="placeholder__n">{game.name}</span>
</div>
)
}

View file

@ -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<MediaKind>('cover')
const [opts, setOpts] = useState<MediaOption[] | null>(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 <FileBrowser filter="image" title={t('media.fromFile')} onClose={() => setFile(false)} onPick={async (p) => { await api['media:setFile'](game.id, kind, p); onClose() }} />
return (
<Modal title={t('media.title')} kicker={game.name} onClose={onClose} wide>
<div className="seg">
{KINDS.map((k) => <button key={k} className={'seg__b' + (k === kind ? ' is-active' : '')} onClick={() => setKind(k)}>{t('media.' + k)}</button>)}
</div>
{!settings.steamGridDbKey && <p className="muted small">{t('media.noKey')}</p>}
{err && <p className="error">{err}</p>}
<div className={'picker picker--' + kind}>
<button className="picker__opt picker__file" data-nav onClick={() => setFile(true)}>{t('media.fromFile')}</button>
{opts === null && <p className="muted">{t('media.loading')}</p>}
{opts?.map((o, i) => (
<button key={o.url + i} className="picker__opt" data-nav disabled={busy} {...(i === 0 ? { 'data-nav-autofocus': true } : {})} onClick={() => pick(o.url)}>
<img src={o.thumb} alt="" loading="lazy" />
</button>
))}
</div>
</Modal>
)
}

View file

@ -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<Values>({ name: game?.name ?? guess(initialExe ?? ''), exe: game?.exe ?? initialExe ?? '', args: game?.args ?? '', workDir: game?.workDir ?? '' })
const [edit, setEdit] = useState<Field | null>(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 <FileBrowser filter={edit === 'exe' ? 'exe' : 'dir'} title={t('edit.' + edit)} onClose={() => setEdit(null)} onPick={(p) => { setV({ ...v, [edit]: p, ...(edit === 'exe' && !v.name ? { name: guess(p) } : {}) }); setEdit(null) }} />
if (edit) return <Keyboard title={t('edit.' + edit)} value={v[edit]} onCancel={() => setEdit(null)} onSubmit={(s) => { setV({ ...v, [edit]: s }); setEdit(null) }} />
const fields: Field[] = canExe ? ['name', 'exe', 'args', 'workDir'] : ['name', 'args']
return (
<Modal title={game ? t('edit.title') : t('edit.addTitle')} kicker={game?.name} onClose={onClose} wide>
<div className="form">
{fields.map((f, i) => (
<button key={f} className="field" data-nav {...(i === 0 ? { 'data-nav-autofocus': true } : {})} onClick={() => setEdit(f)}>
<span className="field__l mono">{t('edit.' + f)}</span>
<span className={'field__v' + (v[f] ? '' : ' muted')}>{v[f] || t('edit.empty')}</span>
</button>
))}
</div>
{err && <p className="error">{err}</p>}
<div className="actions">
<button className="btn" data-nav onClick={onClose}>{t('common.cancel')}</button>
<button className="btn btn--primary" data-nav disabled={!v.exe && !game} onClick={save}>{t('common.save')}</button>
</div>
</Modal>
)
}

View file

@ -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<FsListing | null>(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 (
<Modal title={title} kicker={list?.dir || t('fs.drives')} onClose={onClose} wide>
{err && <p className="error">{err}</p>}
<div className="files">
{list?.parent !== null && list && (
<button className="file file--up" data-nav onClick={() => open(list.parent ?? '')}>↰ {t('fs.up')}</button>
)}
{filter === 'dir' && list?.dir && (
<button className="file file--pick" data-nav onClick={() => onPick(list.dir)}>✓ {t('fs.pickDir')}</button>
)}
{list?.entries.map((e, i) => (
<button key={e.path} className={'file' + (e.dir ? ' file--dir' : '')} data-nav {...(i === 0 ? { 'data-nav-autofocus': true } : {})} onClick={() => (e.dir ? open(e.path) : onPick(e.path))}>
{e.dir ? '▸ ' : ''}{e.name}
</button>
))}
{list && !list.entries.length && <p className="muted">{t('fs.empty')}</p>}
</div>
</Modal>
)
}

View file

@ -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 (
<button
className={'card' + (game.installed ? '' : ' card--off')}
data-nav
data-nav-id={'g:' + game.id}
{...(autofocus ? { 'data-nav-autofocus': true } : {})}
onClick={() => onOpen(game)}
aria-label={game.name}
>
<Cover game={game} />
{game.favorite && <span className="card__fav" aria-hidden>★</span>}
<span className="card__name">{game.name}</span>
</button>
)
})

View file

@ -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<HTMLDivElement>(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(
<div key={g.id} className="grid__cell" style={{ transform: `translate(${c * (cardW + GAP)}px, ${r * rowH}px)`, width: cardW }}>
<GameCard game={g} onOpen={onOpen} autofocus={r === 0 && c === 0} />
</div>
)
}
}
return (
<div className="grid" ref={box} onScroll={(e) => setTop(e.currentTarget.scrollTop)}>
<div className="grid__inner" style={{ height: rows * rowH }}>{items}</div>
</div>
)
}

View file

@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { HintBar } from '../nav/glyphs' import { HintBar } from '../nav/glyphs'
import { useNav } from '../nav/nav' import { useNav } from '../nav/nav'
import { play } from '../nav/sounds'
import { useSettings } from '../state/settings' import { useSettings } from '../state/settings'
import { useT } from '../i18n/i18n' import { useT } from '../i18n/i18n'
@ -46,13 +47,13 @@ export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit,
}) })
useNav((a) => { useNav((a) => {
if (a === 'back') { onCancel(); return true } if (a === 'back') { play('back'); onCancel(); return true }
if (a === 'x') { del(); return true } if (a === 'x') { del(); return true }
if (a === 'y') { type(' '); return true } if (a === 'y') { type(' '); return true }
if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true } if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true }
if (a === 'menu' || a === 'rt') { onSubmit(value); return true } if (a === 'menu' || a === 'rt') { onSubmit(value); return true }
return false return false
}) }, true, 2)
return ( return (
<div className="modal osk" data-nav-scope role="dialog" aria-label={title ?? t('kb.title')}> <div className="modal osk" data-nav-scope role="dialog" aria-label={title ?? t('kb.title')}>

View file

@ -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 (
<div className="list">
{games.map((g, i) => (
<button key={g.id} className="row-item" data-nav data-nav-id={'g:' + g.id} data-nav-scroll="center" {...(i === 0 ? { 'data-nav-autofocus': true } : {})} onClick={() => onOpen(g)}>
<span className="row-item__name">{g.favorite ? '★ ' : ''}{g.name}</span>
<span className="row-item__meta mono">{PLATFORM_LABEL[g.platform]}</span>
<span className="row-item__meta mono">{playtime(g.playtime, settings.language)}</span>
<span className="row-item__meta mono">{g.lastPlayed ? ago(g.lastPlayed, settings.language) : t('time.never')}</span>
</button>
))}
</div>
)
}

View file

@ -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 (
<div className="modal" data-nav-scope role="dialog" aria-label={title}>
<div className={'modal__box' + (wide ? ' modal__box--wide' : '')}>
{kicker && <p className="kicker">{kicker}</p>}
{title && <h2 className="title modal__title">{title}</h2>}
{children}
</div>
<HintBar items={[['accept', 'hint.select'], ['back', 'hint.back']]} />
</div>
)
}
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 (
<Modal title={title} kicker={kicker} onClose={onClose}>
<div className="menu">
{items.map((it, i) => (
<button key={it.label} className={'menu__item' + (it.danger ? ' menu__item--danger' : '')} data-nav {...(i === 0 ? { 'data-nav-autofocus': true } : {})} onClick={it.onSelect}>
{it.checked !== undefined && <span className="menu__check">{it.checked ? '■' : '□'}</span>}
<span>{it.label}</span>
{it.hint && <span className="menu__hint mono">{it.hint}</span>}
</button>
))}
</div>
</Modal>
)
}
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 (
<Modal title={title} onClose={onClose}>
{text && <p className="muted">{text}</p>}
<div className="actions">
<button className="btn" data-nav data-nav-autofocus onClick={onClose}>{cancel}</button>
<button className={'btn ' + (danger ? 'btn--danger' : 'btn--primary')} data-nav onClick={onOk}>{ok}</button>
</div>
</Modal>
)
}

View file

@ -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 (
<section className="row">
<h2 className="row__title kicker">{title}</h2>
<div className="row__track">
{games.map((g, i) => <GameCard key={g.id} game={g} onOpen={onOpen} autofocus={autofocusFirst && i === 0} />)}
</div>
</section>
)
}

View file

@ -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 (
<div className={'splash' + (out ? ' is-out' : '')} aria-hidden>
{s.type === 'orb' && <Orb x={0.5} y={0.5} radius={0.3} />}
{s.type === 'image' && <img src={url(s.src)} alt="" />}
{s.type === 'video' && <video src={url(s.src)} autoPlay muted playsInline />}
<div className="splash__logo">orbe<span>.</span></div>
</div>
)
}

View file

@ -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<Ask>(null)
const pw = (a: PowerAction, title: string, ok: string) => setAsk({ title, ok, run: () => api['system:power'](a) })
if (ask) return <Confirm title={ask.title} ok={ask.ok} cancel={t('common.cancel')} danger onClose={() => setAsk(null)} onOk={() => { ask.run(); setAsk(null); onClose() }} />
const g = running && byId.get(running.gameId)
return (
<Menu title={t('sys.title')} kicker="Orbe Launcher" onClose={onClose} items={[
...(g ? [{ label: t('sys.stopTracking', { name: g.name }), hint: t('sys.stopHint'), onSelect: () => { 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')) }
]} />
)
}

View file

@ -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 (
<header className="topbar">
<span className="topbar__logo">orbe<span>.</span></span>
<nav className="tabs">
<Glyph action="lb" />
{TABS.map((k) => (
<button key={k} className={'tab' + (k === tab ? ' is-active' : '')} onClick={() => setTab(k)}>{t('tab.' + k)}</button>
))}
<Glyph action="rb" />
</nav>
<span className="topbar__end mono">
{scanning && <span className="muted">{t('scan.running')}</span>}
{now.toLocaleTimeString(settings.language === 'fr' ? 'fr-FR' : 'en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
)
}

View file

@ -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: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:setFile': async (id) => games.find((x) => x.id === id)!,
'media:refresh': async () => {}, '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:list': async () => collections,
'collections:create': async (name) => { 'collections:create': async (name) => {
const c = { id: String(Date.now()), name, createdAt: Date.now() } const c = { id: String(Date.now()), name, createdAt: Date.now() }

View file

@ -1,14 +1,159 @@
import type { fr } from './fr' import type { fr } from './fr'
export const en: Record<keyof typeof fr, string> = { export const en: Record<keyof typeof fr, string> = {
'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.select': 'Select',
'hint.back': 'Back', 'hint.back': 'Back',
'hint.ok': 'Confirm', 'hint.ok': 'Confirm',
'hint.delete': 'Delete', 'hint.delete': 'Delete',
'hint.space': 'Space', 'hint.space': 'Space',
'hint.shift': 'Shift', 'hint.shift': 'Shift',
'hint.sort': 'Sort',
'hint.system': 'Menu',
'hint.change': 'Change',
'kb.title': 'Input', 'kb.title': 'Input',
'kb.ok': 'OK', 'kb.ok': 'OK',
'kb.space': 'Space', '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?'
} }

View file

@ -1,12 +1,157 @@
export const fr = { 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.select': 'Choisir',
'hint.back': 'Retour', 'hint.back': 'Retour',
'hint.ok': 'Valider', 'hint.ok': 'Valider',
'hint.delete': 'Effacer', 'hint.delete': 'Effacer',
'hint.space': 'Espace', 'hint.space': 'Espace',
'hint.shift': 'Majuscules', 'hint.shift': 'Majuscules',
'hint.sort': 'Trier',
'hint.system': 'Menu',
'hint.change': 'Changer',
'kb.title': 'Saisie', 'kb.title': 'Saisie',
'kb.ok': 'OK', 'kb.ok': 'OK',
'kb.space': 'Espace', '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 ?'
} }

View file

@ -1,9 +1,9 @@
/* Libellés des boutons selon la manette (pur, testé). */ /* Libellés des boutons selon la manette (pur, testé). */
import type { Action, InputKind } from './input' import type { Action, InputKind } from './input'
const XBOX: Partial<Record<Action, string>> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉' } const XBOX: Partial<Record<Action, string>> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉', left: '◂▸' }
const PS: Partial<Record<Action, string>> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE' } const PS: Partial<Record<Action, string>> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE', left: '◂▸' }
const KEY: Partial<Record<Action, string>> = { accept: 'Entrée', back: 'Échap', x: 'X', y: 'Y', lb: 'Q', rb: 'E', lt: 'PgPréc', rt: 'PgSuiv', menu: 'F1', view: 'Tab' } const KEY: Partial<Record<Action, string>> = { 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 { export function glyph(a: Action, kind: InputKind): string {
return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a

View file

@ -7,11 +7,13 @@ import { play } from './sounds'
import { pickNext, type Dir } from './spatial' import { pickNext, type Dir } from './spatial'
type Handler = (a: Action) => boolean | void // true = action consommée type Handler = (a: Action) => boolean | void // true = action consommée
type Entry = { current: Handler; prio: number; order: number }
let order = 0
interface Ctx { interface Ctx {
kind: InputKind kind: InputKind
focus: (el: Element | null) => void focus: (el: Element | null) => void
push: (h: { current: Handler }) => () => void push: (h: { current: Handler }, prio: number) => () => void
sound: (s: SoundName) => void sound: (s: SoundName) => void
current: () => HTMLElement | null 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 }) { export function NavProvider({ children, onGuide }: { children: ReactNode; onGuide?: () => void }) {
const [kind, setKind] = useState<InputKind>('mouse') const [kind, setKind] = useState<InputKind>('mouse')
const cur = useRef<HTMLElement | null>(null) const cur = useRef<HTMLElement | null>(null)
const stack = useRef<{ current: Handler }[]>([]) const stack = useRef<Entry[]>([]) // trié : priorité puis ordre d'arrivée
const { settings } = useSettings() const { settings } = useSettings()
const vib = useRef(settings.vibration) const vib = useRef(settings.vibration)
vib.current = 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 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) 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 === 'accept' && cur.current) { play('select'); cur.current.click() }
else if (a === 'back') play('back')
}, },
guideHeld: onGuide guideHeld: onGuide
}) })
@ -79,16 +82,23 @@ export function NavProvider({ children, onGuide }: { children: ReactNode; onGuid
return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) } return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) }
}, [move, focus, refocus, onGuide]) }, [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 <NavCtx.Provider value={{ kind, focus, push, sound: play, current: () => cur.current }}>{children}</NavCtx.Provider> return <NavCtx.Provider value={{ kind, focus, push, sound: play, current: () => cur.current }}>{children}</NavCtx.Provider>
} }
export const useNavCtx = () => useContext(NavCtx) export const useNavCtx = () => useContext(NavCtx)
/* Intercepte les actions tant que le composant est affiché (le plus récent passe en premier) */ /* Intercepte les actions tant que le composant est affiché.
export function useNav(handler: Handler, active = true): void { 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 { push } = useNavCtx()
const ref = useRef(handler) const ref = useRef(handler)
ref.current = 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]')

View file

@ -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 (
<main className="screen collections">
<p className="kicker">{t('coll.kicker')}</p>
<h1 className="title">{t('tab.collections')}</h1>
<div className="colls">
<button className="coll coll--new" data-nav data-nav-autofocus onClick={() => setCreating(true)}>+ {t('coll.new')}</button>
{collections.map((c) => {
const inside = games.filter((g) => g.collections.includes(c.id))
return (
<button key={c.id} className="coll" data-nav data-nav-id={'c:' + c.id} onClick={() => go({ name: 'collection', id: c.id })}>
<div className="coll__covers">{inside.slice(0, 3).map((g) => <Cover key={g.id} game={g} />)}</div>
<span className="coll__name">{c.name}</span>
<span className="mono muted">{t('coll.count', { n: inside.length })}</span>
</button>
)
})}
</div>
{!collections.length && <p className="muted">{t('coll.none')}</p>}
<HintBar items={[['accept', 'hint.select']]} />
{creating && (
<Keyboard title={t('coll.new')} value="" max={60} onCancel={() => setCreating(false)} onSubmit={async (v) => { if (v.trim()) { await api['collections:create'](v); await reloadCollections() } setCreating(false) }} />
)}
</main>
)
}

View file

@ -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 | 'more' | 'edit' | 'media' | 'coll' | 'newColl' | 'remove'>(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 <main className="screen"><p className="muted">{t('game.missing')}</p></main>
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 (
<main className="screen detail">
<div className="detail__bg"><Cover game={g} kind="hero" /></div>
<div className="detail__body">
<p className="kicker">{PLATFORM_LABEL[g.platform]}{g.installed ? '' : ' · ' + t('game.notInstalled')}</p>
{g.media.logo ? <img className="detail__logo" src={g.media.logo} alt={g.name} /> : <h1 className="hero-title detail__name">{g.name}</h1>}
<dl className="stats">
<div><dt className="mono">{t('game.playtime')}</dt><dd>{playtime(g.playtime, lang)}</dd></div>
<div><dt className="mono">{t('game.last')}</dt><dd>{ago(g.lastPlayed, lang)}</dd></div>
{g.info?.releaseDate && <div><dt className="mono">{t('game.release')}</dt><dd>{g.info.releaseDate}</dd></div>}
{g.info?.developer && <div><dt className="mono">{t('game.dev')}</dt><dd>{g.info.developer}</dd></div>}
</dl>
<div className="actions">
<button className="btn btn--primary btn--play" data-nav data-nav-autofocus disabled={!g.installed || !!running} onClick={() => launch(g)}>
▶ {isRunning ? t('game.running') : t('game.play')}
</button>
<button className="btn" data-nav onClick={() => api['library:update'](g.id, { favorite: !g.favorite })}>{g.favorite ? '★ ' + t('game.unfav') : '☆ ' + t('game.fav')}</button>
<button className="btn" data-nav onClick={() => setModal('coll')}>{t('game.collections')}</button>
<button className="btn" data-nav onClick={() => setModal('more')}>{t('game.more')}</button>
</div>
{g.info?.genres && <p className="mono muted genres">{g.info.genres.join(' · ')}</p>}
{g.info?.description && <p className="detail__desc">{g.info.description}</p>}
</div>
<div className="detail__cover card"><Cover game={g} /></div>
<HintBar items={[['accept', 'hint.select'], ['y', g.favorite ? 'game.unfav' : 'game.fav'], ['x', 'game.more'], ['back', 'hint.back']]} />
{modal === 'more' && (
<Menu title={g.name} onClose={() => 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' && (
<Menu title={t('game.collections')} kicker={g.name} onClose={() => 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' && (
<Keyboard title={t('coll.new')} value="" max={60} onCancel={() => 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' && <EditGame game={g} onClose={() => setModal(null)} />}
{modal === 'media' && <CoverPicker game={g} onClose={() => setModal(null)} />}
{modal === 'remove' && (
<Confirm title={g.platform === 'manual' ? t('game.removeQ') : t('game.hideQ')} text={g.platform === 'manual' ? t('game.removeText') : t('game.hideText')}
ok={g.platform === 'manual' ? t('game.remove') : t('game.hide')} cancel={t('common.cancel')} danger
onClose={() => setModal(null)} onOk={async () => { await api['library:remove'](g.id); setModal(null); back() }} />
)}
</main>
)
}

View file

@ -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 (
<main className="screen screen--empty">
<p className="kicker">{t('home.kicker')}</p>
<h1 className="hero-title">{scanning ? t('home.scanning') : t('home.empty')}</h1>
<p className="muted">{t('home.emptyText')}</p>
</main>
)
return (
<main className="screen home">
{hero && (
<section className="home__hero">
<div className="home__heroBg"><Cover game={hero} kind="hero" /></div>
<div className="home__heroTxt">
<p className="kicker">{recent[0] ? t('home.continue') : t('home.pick')}</p>
{hero.media.logo ? <img className="home__logo" src={hero.media.logo} alt={hero.name} /> : <h1 className="hero-title">{hero.name}</h1>}
<p className="mono muted">{playtime(hero.playtime, settings.language)} · {ago(hero.lastPlayed, settings.language)}</p>
<button className="btn btn--primary" data-nav data-nav-autofocus data-nav-id="home:hero" onClick={() => open(hero)}>{t('home.open')}</button>
</div>
</section>
)}
<Row title={t('home.recent')} games={recent} onOpen={open} />
<Row title={t('home.favorites')} games={favs} onOpen={open} />
<Row title={t('home.added')} games={added} onOpen={open} />
<HintBar items={[['accept', 'hint.select'], ['menu', 'hint.system']]} />
</main>
)
}

View file

@ -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<Filter>('all')
const [sort, setSort] = useState<Sort>('name')
const [query, setQuery] = useState('')
const [modal, setModal] = useState<null | 'search' | 'add' | 'addForm' | 'collection' | 'rename'>(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 (
<main className="screen library">
<header className="library__head">
<div>
<p className="kicker">{coll ? t('lib.collection') : t('lib.kicker')}</p>
<h1 className="title">{coll?.name ?? t('tab.library')} <span className="muted count">{list.length}</span></h1>
</div>
<div className="toolbar">
{query && <button className="chip chip--on" data-nav onClick={() => setQuery('')}>« {query} » ✕</button>}
<button className="btn" data-nav onClick={() => setModal('search')}>⌕ {t('lib.search')}</button>
<button className="btn" data-nav onClick={() => setSort(SORTS[(SORTS.indexOf(sort) + 1) % SORTS.length])}>{t('sort.' + sort)}</button>
{!collectionId && <button className="btn" data-nav onClick={() => setModal('add')}>+ {t('lib.add')}</button>}
{!collectionId && <button className="btn" data-nav disabled={scanning} onClick={rescan}>{scanning ? t('scan.running') : t('lib.rescan')}</button>}
{collectionId && <button className="btn" data-nav onClick={() => setModal('collection')}>{t('coll.options')}</button>}
</div>
</header>
<div className="chips">
{filters.map((f) => (
<button key={f} className={'chip' + (f === filter ? ' chip--on' : '')} data-nav onClick={() => setFilter(f)}>
{f === 'all' || f === 'installed' || f === 'favorites' ? t('filter.' + f) : PLATFORM_LABEL[f]}
</button>
))}
</div>
{!list.length && <p className="muted empty">{query ? t('lib.noResult', { q: query }) : collectionId ? t('coll.empty') : t('lib.empty')}</p>}
{layout === 'grid' && <Grid games={list} onOpen={open} initialId={focusOnShow} />}
{layout === 'list' && <ListView games={list} onOpen={open} />}
{layout === 'carousel' && (
<div className="carousel">{list.map((g, i) => <GameCard key={g.id} game={g} onOpen={open} autofocus={i === 0} />)}</div>
)}
<HintBar items={[['accept', 'hint.select'], ['x', 'lib.search'], ['y', 'hint.sort'], ['back', 'hint.back']]} />
{modal === 'search' && <Keyboard title={t('lib.search')} value={query} onChange={setQuery} onSubmit={(v) => { setQuery(v); setModal(null) }} onCancel={() => setModal(null)} />}
{modal === 'add' && <FileBrowser filter="exe" title={t('lib.pickExe')} onClose={() => setModal(null)} onPick={(p) => { setExe(p); setModal('addForm') }} />}
{modal === 'addForm' && <EditGame initialExe={exe} onClose={() => setModal(null)} onSaved={open} />}
{modal === 'rename' && coll && (
<Keyboard title={t('coll.rename')} value={coll.name} max={60} onCancel={() => setModal(null)} onSubmit={async (v) => { await api['collections:rename'](coll.id, v); await reloadCollections(); setModal(null) }} />
)}
{modal === 'collection' && coll && (
<Menu title={coll.name} kicker={t('lib.collection')} onClose={() => 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() } }
]} />
)}
</main>
)
}

View file

@ -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 (
<button className="setting" data-nav data-nav-id={'s:' + id} data-nav-scroll="center" data-left={onLeft ? '1' : undefined}
onClick={onSelect ?? onRight}
ref={(el) => { if (el) Object.assign(el, { __left: onLeft, __right: onRight }) }}>
<span className="setting__l">{label}{hint && <small className="muted">{hint}</small>}</span>
<span className="setting__v mono">{onLeft && '‹ '}{value}{onRight && ' ›'}</span>
</button>
)
}
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<AppInfo>()
const [modal, setModal] = useState<null | 'key' | 'folder' | 'kiosk'>(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 = <T,>(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 (
<main className="screen settings">
<p className="kicker">{t('set.kicker')}</p>
<h1 className="title">{t('tab.settings')}</h1>
<div className="settings__cols">
<section>
<h2 className="settings__h mono">{t('set.display')}</h2>
<Line id="theme" label={t('set.theme')} value={theme?.manifest.name} onLeft={() => setTheme(-1)} onRight={() => setTheme(1)} />
<Line id="lang" label={t('set.language')} value={settings.language === 'fr' ? 'Français' : 'English'} onLeft={() => update({ language: settings.language === 'fr' ? 'en' : 'fr' })} onRight={() => update({ language: settings.language === 'fr' ? 'en' : 'fr' })} />
<Line id="fs" label={t('set.fullscreen')} value={onOff(settings.fullscreen)} onSelect={() => update({ fullscreen: !settings.fullscreen })} />
<Line id="vol" label={t('set.volume')} value={<span className="bar"><span style={{ width: settings.volume * 100 + '%' }} /></span>} onLeft={() => vol(-1)} onRight={() => vol(1)} />
<Line id="vib" label={t('set.vibration')} value={onOff(settings.vibration)} onSelect={() => update({ vibration: !settings.vibration })} />
<h2 className="settings__h mono">{t('set.system')}</h2>
<Line id="auto" label={t('set.autostart')} value={onOff(settings.autostart)} onSelect={() => update({ autostart: !settings.autostart })} />
<Line id="kiosk" label={t('set.kiosk')} hint={t('set.kioskHint')} value={onOff(settings.kiosk)} onSelect={() => (settings.kiosk ? update({ kiosk: false }) : setModal('kiosk'))} />
</section>
<section>
<h2 className="settings__h mono">{t('set.library')}</h2>
<Line id="key" label={t('set.sgdb')} hint={t('set.sgdbHint')} value={settings.steamGridDbKey || t('set.none')} onSelect={() => setModal('key')} />
{SCANNERS.map((id) => <Line key={id} id={'sc-' + id} label={id === 'folder' ? t('set.folders') : PLATFORM_LABEL[id]} value={onOff(settings.scanners[id])} onSelect={() => toggleScanner(id)} />)}
{settings.extraFolders.map((f) => <Line key={f} id={'f-' + f} label={f} value={t('set.removeFolder')} onSelect={() => update({ extraFolders: settings.extraFolders.filter((x) => x !== f) })} />)}
<Line id="addf" label={'+ ' + t('set.addFolder')} onSelect={() => setModal('folder')} />
<Line id="scan" label={t('lib.rescan')} value={scanning ? t('scan.running') : lastScan ? t('scan.report', { total: lastScan.total, added: lastScan.added }) : ''} onSelect={rescan} />
<h2 className="settings__h mono">{t('set.about')}</h2>
<p className="mono muted small">Orbe Launcher {info?.version} · {info?.dataDir}</p>
</section>
</div>
<HintBar items={[['accept', 'hint.select'], ['left', 'hint.change'], ['back', 'hint.back']]} />
{modal === 'key' && <Keyboard title={t('set.sgdb')} value="" max={128} onCancel={() => setModal(null)} onSubmit={(v) => { update({ steamGridDbKey: v.trim() }); setModal(null) }} />}
{modal === 'folder' && <FileBrowser filter="dir" title={t('set.addFolder')} onClose={() => setModal(null)} onPick={(p) => { if (!settings.extraFolders.includes(p)) update({ extraFolders: [...settings.extraFolders, p] }); setModal(null) }} />}
{modal === 'kiosk' && <Confirm title={t('set.kiosk')} text={t('set.kioskWarn')} ok={t('common.enable')} cancel={t('common.cancel')} onClose={() => setModal(null)} onOk={() => { update({ kiosk: true }); setModal(null) }} />}
</main>
)
}

View file

@ -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<Sort, (a: Game, b: Game) => 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])
}

View file

@ -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<string, string> = { steam: 'Steam', epic: 'Epic Games', gog: 'GOG', ubisoft: 'Ubisoft', ea: 'EA', folder: 'Dossier', manual: 'PC' }

View file

@ -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<string, Game>
collections: Collection[]
scanning: boolean
lastScan?: ScanReport
rescan: () => void
reloadCollections: () => Promise<void>
}
const LibCtx = createContext<Ctx>(null!)
export function LibraryProvider({ children }: { children: ReactNode }) {
const [all, setAll] = useState<Game[]>([])
const [collections, setCollections] = useState<Collection[]>([])
const [scanning, setScanning] = useState(false)
const [lastScan, setLastScan] = useState<ScanReport>()
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 <LibCtx.Provider value={value}>{children}</LibCtx.Provider>
}
export const useLibrary = () => useContext(LibCtx)

View file

@ -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<Ctx>(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<Entry[]>([{ route: { name: 'home' } }])
const [focusOnShow, setFocusOnShow] = useState<string>()
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 <RouterCtx.Provider value={{ route, tab, go, back, setTab, focusOnShow }}>{children}</RouterCtx.Provider>
}
export const useRouter = () => useContext(RouterCtx)

View file

@ -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<void>
stop: () => void
clearError: () => void
}
const SessionCtx = createContext<Ctx>(null!)
export function SessionProvider({ children }: { children: ReactNode }) {
const [running, setRunning] = useState<RunningGame | null>(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 <SessionCtx.Provider value={{ running, error, launch, stop: () => api['game:stop'](), clearError: () => setError('') }}>{children}</SessionCtx.Provider>
}
export const useSession = () => useContext(SessionCtx)

View file

@ -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; } .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é */ /* 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; } .bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; }
/* Barre d'aide (boutons) */ /* Barre d'aide (boutons) */
@ -77,3 +78,154 @@ mark { background: none; color: var(--accent); }
.osk__key.is-on { 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.is-focused { box-shadow: var(--focus-ring); transform: scale(1.06); background: var(--surface-2); }
.osk__key--ok.is-focused { background: var(--accent); } .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; } }

View file

@ -22,6 +22,9 @@ export interface FsEntry { name: string; path: string; dir: boolean }
export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] } export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] }
export type FsFilter = 'exe' | 'image' | 'dir' 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 type MediaKind = 'cover' | 'hero' | 'logo' | 'icon'
export interface MediaOption { url: string; thumb: string; width?: number; height?: number } 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<Game> 'media:set': (gameId: string, kind: MediaKind, url: string) => Promise<Game>
'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise<Game> 'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise<Game>
'media:refresh': (gameId: string) => Promise<void> 'media:refresh': (gameId: string) => Promise<void>
'game:launch': (id: string) => Promise<void>
'game:stop': () => Promise<void>
'session:get': () => Promise<RunningGame | null>
'system:power': (action: PowerAction) => Promise<void>
'system:quit': () => Promise<void>
'collections:list': () => Promise<Collection[]> 'collections:list': () => Promise<Collection[]>
'collections:create': (name: string) => Promise<Collection> 'collections:create': (name: string) => Promise<Collection>
'collections:rename': (id: string, name: string) => Promise<void> 'collections:rename': (id: string, name: string) => Promise<void>
@ -57,6 +65,8 @@ export interface Api {
export interface Events { export interface Events {
'library:changed': Game[] 'library:changed': Game[]
'scan:state': { running: boolean; report?: ScanReport } 'scan:state': { running: boolean; report?: ScanReport }
'session:state': { running: RunningGame | null; last?: { gameId: string; duration: number } }
'app:front': true
'settings:changed': Settings 'settings:changed': Settings
'themes:changed': { id: string; themes: ThemeEntry[] } 'themes:changed': { id: string; themes: ThemeEntry[] }
} }
@ -76,6 +86,11 @@ export const CHANNELS = [
'media:set', 'media:set',
'media:setFile', 'media:setFile',
'media:refresh', 'media:refresh',
'game:launch',
'game:stop',
'session:get',
'system:power',
'system:quit',
'collections:list', 'collections:list',
'collections:create', 'collections:create',
'collections:rename', 'collections:rename',
@ -85,7 +100,7 @@ export const CHANNELS = [
'themes:list' 'themes:list'
] as const satisfies readonly Channel[] ] 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 */ /* Ce que l'interface voit dans window.orbe */
export type OrbeApi = Api & { export type OrbeApi = Api & {

View file

@ -1,8 +1,8 @@
/* Types partagés entre le processus principal et l'interface */ /* 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<Platform, 'manual'> export type ScannerId = Exclude<Platform, 'manual'>
export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea'] export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea', 'folder']
export interface GameMedia { export interface GameMedia {
cover?: string // URL servie par orbe-media:// (cache) ou fichier choisi par l'utilisateur cover?: string // URL servie par orbe-media:// (cache) ou fichier choisi par l'utilisateur
@ -63,7 +63,7 @@ export interface Settings {
theme: string theme: string
steamGridDbKey: string steamGridDbKey: string
scanners: Record<ScannerId, boolean> scanners: Record<ScannerId, boolean>
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 autostart: boolean
fullscreen: boolean fullscreen: boolean
kiosk: boolean kiosk: boolean
@ -75,7 +75,7 @@ export interface Settings {
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
theme: 'orbe', theme: 'orbe',
steamGridDbKey: '', 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: [], extraFolders: [],
autostart: false, autostart: false,
fullscreen: true, fullscreen: true,

24
tests/library-ui.test.ts Normal file
View file

@ -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> = {}): 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<typeof filterGames>[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'])
})
})

30
tests/match.test.ts Normal file
View file

@ -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([])
})
})

View file

@ -97,3 +97,19 @@ describe('runScanners', () => {
expect(r.ok.gog).toBeUndefined() 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<string, string[]> = {
'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<string, number> = { '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()
})
})