É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
Some checks are pending
CI / windows (push) Waiting to run
This commit is contained in:
parent
baa9f7391c
commit
1cfe89d1fd
43 changed files with 1890 additions and 45 deletions
|
|
@ -7,6 +7,10 @@ import { initLog, log } from './log'
|
|||
import { Media } from './media'
|
||||
import { MediaCache } from './media/cache'
|
||||
import { registerMedia } from './media-ipc'
|
||||
import { Sessions } from './launch/session'
|
||||
import { power, type PowerAction } from './win/power'
|
||||
import { watchGuide } from './win/xinput'
|
||||
import { str } from './validate'
|
||||
import { Store } from './store'
|
||||
import { registerThemeScheme, Themes } from './themes'
|
||||
|
||||
|
|
@ -54,12 +58,19 @@ function createWindow(): BrowserWindow {
|
|||
return w
|
||||
}
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
/* Ramène le launcher devant le jeu (Windows refuse souvent un simple focus() : passage temporaire au premier plan absolu) */
|
||||
function toFront(): void {
|
||||
if (!win) return
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.setAlwaysOnTop(true, 'screen-saver')
|
||||
win.focus()
|
||||
app.focus({ steal: true })
|
||||
setTimeout(() => win?.setAlwaysOnTop(false), 400)
|
||||
emit(win, 'app:front', true)
|
||||
}
|
||||
|
||||
app.on('second-instance', toFront)
|
||||
|
||||
app.whenReady().then(() => {
|
||||
store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined)
|
||||
|
|
@ -70,6 +81,9 @@ app.whenReady().then(() => {
|
|||
onSettings: (s: typeof store.settings, before: typeof store.settings) => {
|
||||
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
||||
if (s.steamGridDbKey !== before.steamGridDbKey) { media.keyChanged(); media.enqueue(media.missing()) }
|
||||
if (s.kiosk !== before.kiosk) win?.setKiosk(s.kiosk)
|
||||
if (s.autostart !== before.autostart && app.isPackaged) app.setLoginItemSettings({ openAtLogin: s.autostart, args: ['--autostart'] })
|
||||
if (JSON.stringify(s.scanners) !== JSON.stringify(before.scanners) || s.extraFolders.join() !== before.extraFolders.join()) library.rescan()
|
||||
}
|
||||
}
|
||||
const cache = new MediaCache(join(dataDir, 'media'))
|
||||
|
|
@ -78,6 +92,23 @@ app.whenReady().then(() => {
|
|||
const media = new Media(ctx, cache)
|
||||
registerMedia(ctx, media)
|
||||
const library = registerLibrary(ctx, (ids) => media.enqueue(ids))
|
||||
|
||||
const sessions = new Sessions(ctx, toFront, () => win?.minimize())
|
||||
handle('game:launch', async (id) => {
|
||||
const g = store.games.get(str(id, 300, 'id'))
|
||||
if (!g) throw new Error('jeu introuvable')
|
||||
await sessions.launch(g)
|
||||
})
|
||||
handle('game:stop', async () => sessions.stop())
|
||||
handle('session:get', async () => sessions.running)
|
||||
handle('system:quit', async () => app.quit())
|
||||
handle('system:power', async (a) => {
|
||||
if (!['sleep', 'restart', 'shutdown'].includes(a)) throw new Error('action inconnue')
|
||||
store.flush()
|
||||
power(a as PowerAction)
|
||||
})
|
||||
// Guide (ou Back + Start) tenu : retour au launcher, même pendant un jeu
|
||||
watchGuide(() => { if (!win?.isFocused()) toFront() })
|
||||
themes.registerProtocol()
|
||||
handle('themes:list', async () => themes.list())
|
||||
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))
|
||||
|
|
|
|||
18
src/main/launch/match.ts
Normal file
18
src/main/launch/match.ts
Normal 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
101
src/main/launch/session.ts
Normal 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 }
|
||||
}
|
||||
42
src/main/scanners/folder.ts
Normal file
42
src/main/scanners/folder.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { ScannerId, Settings } from '../../shared/types'
|
|||
import { log } from '../log'
|
||||
import { ea } from './ea'
|
||||
import { epic } from './epic'
|
||||
import { folderScanner } from './folder'
|
||||
import { gog } from './gog'
|
||||
import { regQuery } from './reg'
|
||||
import { steam } from './steam'
|
||||
|
|
@ -28,7 +29,7 @@ export interface ScanResult {
|
|||
|
||||
const withTimeout = <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: [] }
|
||||
await Promise.all(
|
||||
scanners.filter((s) => settings.scanners[s.id]).map(async (s) => {
|
||||
|
|
|
|||
13
src/main/win/power.ts
Normal file
13
src/main/win/power.ts
Normal 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
55
src/main/win/procs.ts
Normal 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
41
src/main/win/xinput.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -1,28 +1,75 @@
|
|||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import { Background } from './components/Background'
|
||||
import { Keyboard } from './components/Keyboard'
|
||||
import { HintBar } from './nav/glyphs'
|
||||
import { NavProvider } from './nav/nav'
|
||||
import { Modal } from './components/Modal'
|
||||
import { Splash } from './components/Splash'
|
||||
import { SystemMenu } from './components/SystemMenu'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { useT } from './i18n/i18n'
|
||||
import { modalOpen, NavProvider, useNav, useNavCtx } from './nav/nav'
|
||||
import { play } from './nav/sounds'
|
||||
import { Collections } from './screens/Collections'
|
||||
import { GameDetail } from './screens/GameDetail'
|
||||
import { Home } from './screens/Home'
|
||||
import { Library } from './screens/Library'
|
||||
import { Settings } from './screens/Settings'
|
||||
import { LibraryProvider, useLibrary } from './state/library'
|
||||
import { RouterProvider, TABS, useRouter } from './state/router'
|
||||
import { SessionProvider, useSession } from './state/session'
|
||||
import { SettingsProvider } from './state/settings'
|
||||
import { ThemeProvider } from './theme/theme'
|
||||
import './styles/base.css'
|
||||
|
||||
function Preview() {
|
||||
const [kb, setKb] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
function Shell() {
|
||||
const t = useT()
|
||||
const { route, tab, setTab, back, focusOnShow } = useRouter()
|
||||
const { running, error, clearError } = useSession()
|
||||
const { byId } = useLibrary()
|
||||
const { focus } = useNavCtx()
|
||||
const [system, setSystem] = useState(false)
|
||||
const [splash, setSplash] = useState(true)
|
||||
|
||||
// actions globales : onglets (LB/RB), retour, menu système
|
||||
useNav((a) => {
|
||||
if (modalOpen()) return false
|
||||
if (a === 'lb' || a === 'rb') { play('move'); setTab(TABS[(TABS.indexOf(tab) + (a === 'rb' ? 1 : TABS.length - 1)) % TABS.length]); return true }
|
||||
if (a === 'back') { if (back()) play('back'); return true }
|
||||
if (a === 'menu') { play('select'); setSystem(true); return true }
|
||||
return false
|
||||
}, true, 0)
|
||||
const endSplash = useCallback(() => setSplash(false), [])
|
||||
// retour sur un écran : focus remis sur l'élément quitté
|
||||
useEffect(() => {
|
||||
if (!focusOnShow) return
|
||||
const id = requestAnimationFrame(() => focus(document.querySelector(`[data-nav-id="${CSS.escape(focusOnShow)}"]`)))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [route, focusOnShow, focus])
|
||||
useEffect(() => api.on('app:front', () => setSystem(false)), [])
|
||||
useEffect(() => { document.documentElement.dataset.route = route.name }, [route])
|
||||
|
||||
const g = running && byId.get(running.gameId)
|
||||
return (
|
||||
<main style={{ padding: 'var(--pad)' }}>
|
||||
<Background paused={false} />
|
||||
<p className="kicker">Navigation</p>
|
||||
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1>
|
||||
<p className="muted">{text || '…'}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 140px)', gap: 18, marginTop: 32 }}>
|
||||
{Array.from({ length: 12 }, (_, i) => <button key={i} className="card" data-nav style={{ height: 210 }} />)}
|
||||
<>
|
||||
<Background paused={!!running} />
|
||||
<TopBar />
|
||||
<div className="view" key={JSON.stringify(route)}>
|
||||
{route.name === 'home' && <Home />}
|
||||
{route.name === 'library' && <Library />}
|
||||
{route.name === 'collections' && <Collections />}
|
||||
{route.name === 'collection' && <Library collectionId={route.id} />}
|
||||
{route.name === 'settings' && <Settings />}
|
||||
{route.name === 'game' && <GameDetail id={route.id} />}
|
||||
</div>
|
||||
<button className="btn btn--primary" data-nav style={{ marginTop: 32 }} onClick={() => setKb(true)}>Clavier</button>
|
||||
{kb && <Keyboard value={text} onSubmit={(v) => { setText(v); setKb(false) }} onCancel={() => setKb(false)} />}
|
||||
{!kb && <HintBar items={[['accept', 'hint.select'], ['back', 'hint.back']]} />}
|
||||
</main>
|
||||
{g && <div className="playing mono">▶ {t('game.playing', { name: g.name })}{!running.tracked && ' · ' + t('game.waiting')}</div>}
|
||||
{system && <SystemMenu onClose={() => setSystem(false)} />}
|
||||
{error && (
|
||||
<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>
|
||||
<ThemeProvider>
|
||||
<NavProvider>
|
||||
<Preview />
|
||||
<LibraryProvider>
|
||||
<SessionProvider>
|
||||
<RouterProvider>
|
||||
<Shell />
|
||||
</RouterProvider>
|
||||
</SessionProvider>
|
||||
</LibraryProvider>
|
||||
</NavProvider>
|
||||
</ThemeProvider>
|
||||
</SettingsProvider>
|
||||
|
|
|
|||
16
src/renderer/src/components/Cover.tsx
Normal file
16
src/renderer/src/components/Cover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/renderer/src/components/CoverPicker.tsx
Normal file
52
src/renderer/src/components/CoverPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
src/renderer/src/components/EditGame.tsx
Normal file
55
src/renderer/src/components/EditGame.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/renderer/src/components/FileBrowser.tsx
Normal file
36
src/renderer/src/components/FileBrowser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/renderer/src/components/GameCard.tsx
Normal file
27
src/renderer/src/components/GameCard.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
47
src/renderer/src/components/Grid.tsx
Normal file
47
src/renderer/src/components/Grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { HintBar } from '../nav/glyphs'
|
||||
import { useNav } from '../nav/nav'
|
||||
import { play } from '../nav/sounds'
|
||||
import { useSettings } from '../state/settings'
|
||||
import { useT } from '../i18n/i18n'
|
||||
|
||||
|
|
@ -46,13 +47,13 @@ export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit,
|
|||
})
|
||||
|
||||
useNav((a) => {
|
||||
if (a === 'back') { onCancel(); return true }
|
||||
if (a === 'back') { play('back'); onCancel(); return true }
|
||||
if (a === 'x') { del(); return true }
|
||||
if (a === 'y') { type(' '); return true }
|
||||
if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true }
|
||||
if (a === 'menu' || a === 'rt') { onSubmit(value); return true }
|
||||
return false
|
||||
})
|
||||
}, true, 2)
|
||||
|
||||
return (
|
||||
<div className="modal osk" data-nav-scope role="dialog" aria-label={title ?? t('kb.title')}>
|
||||
|
|
|
|||
22
src/renderer/src/components/ListView.tsx
Normal file
22
src/renderer/src/components/ListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/renderer/src/components/Modal.tsx
Normal file
49
src/renderer/src/components/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
src/renderer/src/components/Row.tsx
Normal file
15
src/renderer/src/components/Row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/renderer/src/components/Splash.tsx
Normal file
25
src/renderer/src/components/Splash.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/renderer/src/components/SystemMenu.tsx
Normal file
29
src/renderer/src/components/SystemMenu.tsx
Normal 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')) }
|
||||
]} />
|
||||
)
|
||||
}
|
||||
32
src/renderer/src/components/TopBar.tsx
Normal file
32
src/renderer/src/components/TopBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -58,6 +58,11 @@ export const mock: OrbeApi = {
|
|||
'media:set': async (id, kind, url) => { const g = games.find((x) => x.id === id)!; g.media[kind] = url; fire('library:changed', games); return g },
|
||||
'media:setFile': async (id) => games.find((x) => x.id === id)!,
|
||||
'media:refresh': async () => {},
|
||||
'game:launch': async (id) => { fire('session:state', { running: { gameId: id, start: Date.now(), tracked: true } }) },
|
||||
'game:stop': async () => { fire('session:state', { running: null }) },
|
||||
'session:get': async () => null,
|
||||
'system:power': async () => {},
|
||||
'system:quit': async () => {},
|
||||
'collections:list': async () => collections,
|
||||
'collections:create': async (name) => {
|
||||
const c = { id: String(Date.now()), name, createdAt: Date.now() }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,159 @@
|
|||
import type { fr } from './fr'
|
||||
|
||||
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.back': 'Back',
|
||||
'hint.ok': 'Confirm',
|
||||
'hint.delete': 'Delete',
|
||||
'hint.space': 'Space',
|
||||
'hint.shift': 'Shift',
|
||||
'hint.sort': 'Sort',
|
||||
'hint.system': 'Menu',
|
||||
'hint.change': 'Change',
|
||||
|
||||
'kb.title': 'Input',
|
||||
'kb.ok': 'OK',
|
||||
'kb.space': 'Space',
|
||||
'kb.delete': 'Delete'
|
||||
'kb.delete': 'Delete',
|
||||
|
||||
'time.never': 'Never',
|
||||
'time.hm': '{h} h {m}',
|
||||
'time.m': '{m} min',
|
||||
'time.justNow': 'Just now',
|
||||
'time.today': 'Today',
|
||||
'time.yesterday': 'Yesterday',
|
||||
'time.days': '{n} days ago',
|
||||
|
||||
'scan.running': 'Scanning…',
|
||||
'scan.report': '{total} games, {added} new',
|
||||
|
||||
'home.kicker': 'Welcome',
|
||||
'home.scanning': 'Looking for your games…',
|
||||
'home.empty': 'No games found',
|
||||
'home.emptyText': 'Steam, Epic, GOG, Ubisoft and EA are detected automatically. You can also add a game from the library.',
|
||||
'home.continue': 'Continue',
|
||||
'home.pick': 'Pick something',
|
||||
'home.open': 'View game',
|
||||
'home.recent': 'Recently played',
|
||||
'home.favorites': 'Favorites',
|
||||
'home.added': 'Recently added',
|
||||
|
||||
'lib.kicker': 'All your games',
|
||||
'lib.collection': 'Collection',
|
||||
'lib.search': 'Search',
|
||||
'lib.add': 'Add a game',
|
||||
'lib.rescan': 'Rescan',
|
||||
'lib.empty': 'No games here.',
|
||||
'lib.noResult': 'No game matches “{q}”.',
|
||||
'lib.pickExe': 'Choose the game executable',
|
||||
|
||||
'filter.all': 'All',
|
||||
'filter.installed': 'Installed',
|
||||
'filter.favorites': 'Favorites',
|
||||
'sort.name': 'Name',
|
||||
'sort.playtime': 'Play time',
|
||||
'sort.recent': 'Last played',
|
||||
'sort.added': 'Date added',
|
||||
|
||||
'coll.kicker': 'Your shelves',
|
||||
'coll.new': 'New collection',
|
||||
'coll.none': 'Create a collection, then add games from their page.',
|
||||
'coll.count': '{n} games',
|
||||
'coll.empty': 'Empty collection: add games from their page.',
|
||||
'coll.options': 'Options',
|
||||
'coll.rename': 'Rename',
|
||||
'coll.delete': 'Delete collection',
|
||||
|
||||
'game.missing': 'This game no longer exists.',
|
||||
'game.notInstalled': 'not installed',
|
||||
'game.playtime': 'Play time',
|
||||
'game.last': 'Last session',
|
||||
'game.release': 'Released',
|
||||
'game.dev': 'Studio',
|
||||
'game.play': 'Play',
|
||||
'game.running': 'Running',
|
||||
'game.fav': 'Favorite',
|
||||
'game.unfav': 'Remove from favorites',
|
||||
'game.collections': 'Collections',
|
||||
'game.more': 'More',
|
||||
'game.edit': 'Edit',
|
||||
'game.media': 'Change artwork',
|
||||
'game.refreshMedia': 'Download artwork again',
|
||||
'game.remove': 'Remove',
|
||||
'game.hide': 'Hide',
|
||||
'game.removeQ': 'Remove this game from the launcher?',
|
||||
'game.removeText': 'The game stays on disk, only its entry is removed.',
|
||||
'game.hideQ': 'Hide this game?',
|
||||
'game.hideText': 'It will not show up again, even after a new scan.',
|
||||
'game.playing': 'Playing: {name}',
|
||||
'game.waiting': 'waiting for the game',
|
||||
'game.launchError': 'Could not launch',
|
||||
|
||||
'edit.title': 'Edit game',
|
||||
'edit.addTitle': 'Add a game',
|
||||
'edit.name': 'Name',
|
||||
'edit.exe': 'Executable',
|
||||
'edit.args': 'Arguments',
|
||||
'edit.workDir': 'Working folder',
|
||||
'edit.empty': 'Not set',
|
||||
|
||||
'media.title': 'Game artwork',
|
||||
'media.cover': 'Cover',
|
||||
'media.hero': 'Background',
|
||||
'media.logo': 'Logo',
|
||||
'media.icon': 'Icon',
|
||||
'media.fromFile': 'From a file',
|
||||
'media.loading': 'Loading…',
|
||||
'media.noKey': 'Add a SteamGridDB key in settings for more choices.',
|
||||
'media.failed': 'Download failed.',
|
||||
|
||||
'fs.drives': 'Drives',
|
||||
'fs.up': 'Parent folder',
|
||||
'fs.pickDir': 'Choose this folder',
|
||||
'fs.empty': 'Nothing here.',
|
||||
'fs.denied': 'Folder not accessible.',
|
||||
|
||||
'set.kicker': 'Preferences',
|
||||
'set.display': 'Display and sound',
|
||||
'set.theme': 'Theme',
|
||||
'set.language': 'Language',
|
||||
'set.fullscreen': 'Fullscreen',
|
||||
'set.volume': 'Sound volume',
|
||||
'set.vibration': 'Controller vibration',
|
||||
'set.system': 'System',
|
||||
'set.autostart': 'Start with Windows',
|
||||
'set.kiosk': 'Kiosk mode',
|
||||
'set.kioskHint': 'Can only be closed from the system menu',
|
||||
'set.kioskWarn': 'The launcher stays fullscreen and cannot be closed with the keyboard. To fully replace the Windows desktop, follow the README.',
|
||||
'set.library': 'Library',
|
||||
'set.sgdb': 'SteamGridDB API key',
|
||||
'set.sgdbHint': 'steamgriddb.com, profile, preferences, API',
|
||||
'set.none': 'None',
|
||||
'set.folders': 'Game folders',
|
||||
'set.addFolder': 'Add a game folder',
|
||||
'set.removeFolder': 'Remove',
|
||||
'set.about': 'About',
|
||||
|
||||
'sys.title': 'Menu',
|
||||
'sys.stopTracking': 'Back to the launcher ({name})',
|
||||
'sys.stopHint': 'stops time tracking',
|
||||
'sys.quit': 'Quit the launcher',
|
||||
'sys.quitQ': 'Quit Orbe Launcher?',
|
||||
'sys.sleep': 'Sleep',
|
||||
'sys.sleepQ': 'Put the PC to sleep?',
|
||||
'sys.restart': 'Restart',
|
||||
'sys.restartQ': 'Restart the PC?',
|
||||
'sys.shutdown': 'Shut down',
|
||||
'sys.shutdownQ': 'Shut down the PC?'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,157 @@
|
|||
export const fr = {
|
||||
'common.cancel': 'Annuler',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.on': 'Activé',
|
||||
'common.off': 'Désactivé',
|
||||
'common.enable': 'Activer',
|
||||
|
||||
'tab.home': 'Accueil',
|
||||
'tab.library': 'Bibliothèque',
|
||||
'tab.collections': 'Collections',
|
||||
'tab.settings': 'Paramètres',
|
||||
|
||||
'hint.select': 'Choisir',
|
||||
'hint.back': 'Retour',
|
||||
'hint.ok': 'Valider',
|
||||
'hint.delete': 'Effacer',
|
||||
'hint.space': 'Espace',
|
||||
'hint.shift': 'Majuscules',
|
||||
'hint.sort': 'Trier',
|
||||
'hint.system': 'Menu',
|
||||
'hint.change': 'Changer',
|
||||
|
||||
'kb.title': 'Saisie',
|
||||
'kb.ok': 'OK',
|
||||
'kb.space': 'Espace',
|
||||
'kb.delete': 'Effacer'
|
||||
'kb.delete': 'Effacer',
|
||||
|
||||
'time.never': 'Jamais',
|
||||
'time.hm': '{h} h {m}',
|
||||
'time.m': '{m} min',
|
||||
'time.justNow': 'À l’instant',
|
||||
'time.today': 'Aujourd’hui',
|
||||
'time.yesterday': 'Hier',
|
||||
'time.days': 'Il y a {n} jours',
|
||||
|
||||
'scan.running': 'Scan en cours…',
|
||||
'scan.report': '{total} jeux, {added} nouveaux',
|
||||
|
||||
'home.kicker': 'Bienvenue',
|
||||
'home.scanning': 'Recherche de vos jeux…',
|
||||
'home.empty': 'Aucun jeu trouvé',
|
||||
'home.emptyText': 'Steam, Epic, GOG, Ubisoft et EA sont détectés tout seuls. Vous pouvez aussi ajouter un jeu depuis la bibliothèque.',
|
||||
'home.continue': 'Reprendre',
|
||||
'home.pick': 'À découvrir',
|
||||
'home.open': 'Voir le jeu',
|
||||
'home.recent': 'Derniers lancés',
|
||||
'home.favorites': 'Favoris',
|
||||
'home.added': 'Ajoutés récemment',
|
||||
|
||||
'lib.kicker': 'Tous vos jeux',
|
||||
'lib.collection': 'Collection',
|
||||
'lib.search': 'Rechercher',
|
||||
'lib.add': 'Ajouter un jeu',
|
||||
'lib.rescan': 'Rescanner',
|
||||
'lib.empty': 'Aucun jeu ici.',
|
||||
'lib.noResult': 'Aucun jeu pour « {q} ».',
|
||||
'lib.pickExe': 'Choisir l’exécutable du jeu',
|
||||
|
||||
'filter.all': 'Tous',
|
||||
'filter.installed': 'Installés',
|
||||
'filter.favorites': 'Favoris',
|
||||
'sort.name': 'Nom',
|
||||
'sort.playtime': 'Temps de jeu',
|
||||
'sort.recent': 'Dernier lancement',
|
||||
'sort.added': 'Date d’ajout',
|
||||
|
||||
'coll.kicker': 'Vos rangements',
|
||||
'coll.new': 'Nouvelle collection',
|
||||
'coll.none': 'Créez une collection, puis ajoutez-y des jeux depuis leur fiche.',
|
||||
'coll.count': '{n} jeux',
|
||||
'coll.empty': 'Collection vide : ajoutez des jeux depuis leur fiche.',
|
||||
'coll.options': 'Options',
|
||||
'coll.rename': 'Renommer',
|
||||
'coll.delete': 'Supprimer la collection',
|
||||
|
||||
'game.missing': 'Ce jeu n’existe plus.',
|
||||
'game.notInstalled': 'non installé',
|
||||
'game.playtime': 'Temps de jeu',
|
||||
'game.last': 'Dernière session',
|
||||
'game.release': 'Sortie',
|
||||
'game.dev': 'Studio',
|
||||
'game.play': 'Jouer',
|
||||
'game.running': 'En cours',
|
||||
'game.fav': 'Favori',
|
||||
'game.unfav': 'Retirer des favoris',
|
||||
'game.collections': 'Collections',
|
||||
'game.more': 'Plus',
|
||||
'game.edit': 'Modifier',
|
||||
'game.media': 'Changer les images',
|
||||
'game.refreshMedia': 'Retélécharger les images',
|
||||
'game.remove': 'Supprimer',
|
||||
'game.hide': 'Masquer',
|
||||
'game.removeQ': 'Supprimer ce jeu du launcher ?',
|
||||
'game.removeText': 'Le jeu reste sur le disque, seule sa fiche est supprimée.',
|
||||
'game.hideQ': 'Masquer ce jeu ?',
|
||||
'game.hideText': 'Il n’apparaîtra plus, même après un nouveau scan.',
|
||||
'game.playing': 'En jeu : {name}',
|
||||
'game.waiting': 'en attente du jeu',
|
||||
'game.launchError': 'Lancement impossible',
|
||||
|
||||
'edit.title': 'Modifier le jeu',
|
||||
'edit.addTitle': 'Ajouter un jeu',
|
||||
'edit.name': 'Nom',
|
||||
'edit.exe': 'Exécutable',
|
||||
'edit.args': 'Arguments',
|
||||
'edit.workDir': 'Dossier de travail',
|
||||
'edit.empty': 'Non renseigné',
|
||||
|
||||
'media.title': 'Images du jeu',
|
||||
'media.cover': 'Jaquette',
|
||||
'media.hero': 'Fond',
|
||||
'media.logo': 'Logo',
|
||||
'media.icon': 'Icône',
|
||||
'media.fromFile': 'Depuis un fichier',
|
||||
'media.loading': 'Chargement…',
|
||||
'media.noKey': 'Ajoutez une clé SteamGridDB dans les paramètres pour avoir plus de choix.',
|
||||
'media.failed': 'Téléchargement impossible.',
|
||||
|
||||
'fs.drives': 'Lecteurs',
|
||||
'fs.up': 'Dossier parent',
|
||||
'fs.pickDir': 'Choisir ce dossier',
|
||||
'fs.empty': 'Rien ici.',
|
||||
'fs.denied': 'Dossier inaccessible.',
|
||||
|
||||
'set.kicker': 'Réglages',
|
||||
'set.display': 'Affichage et sons',
|
||||
'set.theme': 'Thème',
|
||||
'set.language': 'Langue',
|
||||
'set.fullscreen': 'Plein écran',
|
||||
'set.volume': 'Volume des sons',
|
||||
'set.vibration': 'Vibration de la manette',
|
||||
'set.system': 'Système',
|
||||
'set.autostart': 'Lancer au démarrage de Windows',
|
||||
'set.kiosk': 'Mode kiosque',
|
||||
'set.kioskHint': 'Impossible de quitter sans le menu système',
|
||||
'set.kioskWarn': 'Le launcher reste en plein écran et ne peut plus être fermé au clavier. Pour remplacer complètement le bureau Windows, suivez le README.',
|
||||
'set.library': 'Bibliothèque',
|
||||
'set.sgdb': 'Clé API SteamGridDB',
|
||||
'set.sgdbHint': 'steamgriddb.com, profil, préférences, API',
|
||||
'set.none': 'Aucune',
|
||||
'set.folders': 'Dossiers de jeux',
|
||||
'set.addFolder': 'Ajouter un dossier de jeux',
|
||||
'set.removeFolder': 'Retirer',
|
||||
'set.about': 'À propos',
|
||||
|
||||
'sys.title': 'Menu',
|
||||
'sys.stopTracking': 'Revenir au launcher ({name})',
|
||||
'sys.stopHint': 'arrête le suivi du temps',
|
||||
'sys.quit': 'Quitter le launcher',
|
||||
'sys.quitQ': 'Quitter Orbe Launcher ?',
|
||||
'sys.sleep': 'Mettre en veille',
|
||||
'sys.sleepQ': 'Mettre le PC en veille ?',
|
||||
'sys.restart': 'Redémarrer',
|
||||
'sys.restartQ': 'Redémarrer le PC ?',
|
||||
'sys.shutdown': 'Éteindre',
|
||||
'sys.shutdownQ': 'Éteindre le PC ?'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/* Libellés des boutons selon la manette (pur, testé). */
|
||||
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 PS: Partial<Record<Action, string>> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE' }
|
||||
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 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', 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', left: '←→' }
|
||||
|
||||
export function glyph(a: Action, kind: InputKind): string {
|
||||
return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import { play } from './sounds'
|
|||
import { pickNext, type Dir } from './spatial'
|
||||
|
||||
type Handler = (a: Action) => boolean | void // true = action consommée
|
||||
type Entry = { current: Handler; prio: number; order: number }
|
||||
let order = 0
|
||||
|
||||
interface Ctx {
|
||||
kind: InputKind
|
||||
focus: (el: Element | null) => void
|
||||
push: (h: { current: Handler }) => () => void
|
||||
push: (h: { current: Handler }, prio: number) => () => void
|
||||
sound: (s: SoundName) => void
|
||||
current: () => HTMLElement | null
|
||||
}
|
||||
|
|
@ -29,7 +31,7 @@ const visible = (el: Element) => { const r = el.getBoundingClientRect(); return
|
|||
export function NavProvider({ children, onGuide }: { children: ReactNode; onGuide?: () => void }) {
|
||||
const [kind, setKind] = useState<InputKind>('mouse')
|
||||
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 vib = useRef(settings.vibration)
|
||||
vib.current = settings.vibration
|
||||
|
|
@ -69,6 +71,7 @@ export function NavProvider({ children, onGuide }: { children: ReactNode; onGuid
|
|||
for (let i = stack.current.length - 1; i >= 0; i--) if (stack.current[i].current(a)) return
|
||||
if (a === 'up' || a === 'down' || a === 'left' || a === 'right') move(a)
|
||||
else if (a === 'accept' && cur.current) { play('select'); cur.current.click() }
|
||||
else if (a === 'back') play('back')
|
||||
},
|
||||
guideHeld: onGuide
|
||||
})
|
||||
|
|
@ -79,16 +82,23 @@ export function NavProvider({ children, onGuide }: { children: ReactNode; onGuid
|
|||
return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) }
|
||||
}, [move, focus, refocus, onGuide])
|
||||
|
||||
const push = useCallback((h: { current: Handler }) => { stack.current.push(h); return () => { stack.current = stack.current.filter((x) => x !== h) } }, [])
|
||||
const push = useCallback((h: { current: Handler }, prio: number) => {
|
||||
const e: Entry = { get current() { return h.current }, prio, order: order++ }
|
||||
stack.current = [...stack.current, e].sort((a, b) => a.prio - b.prio || a.order - b.order)
|
||||
return () => { stack.current = stack.current.filter((x) => x !== e) }
|
||||
}, [])
|
||||
return <NavCtx.Provider value={{ kind, focus, push, sound: play, current: () => cur.current }}>{children}</NavCtx.Provider>
|
||||
}
|
||||
|
||||
export const useNavCtx = () => useContext(NavCtx)
|
||||
|
||||
/* Intercepte les actions tant que le composant est affiché (le plus récent passe en premier) */
|
||||
export function useNav(handler: Handler, active = true): void {
|
||||
/* Intercepte les actions tant que le composant est affiché.
|
||||
Priorité : 0 = coque de l'appli, 1 = écran, 2 = fenêtre modale ; à priorité égale, le plus récent passe en premier. */
|
||||
export function useNav(handler: Handler, active = true, prio = 1): void {
|
||||
const { push } = useNavCtx()
|
||||
const ref = useRef(handler)
|
||||
ref.current = handler
|
||||
useEffect(() => (active ? push(ref) : undefined), [push, active])
|
||||
useEffect(() => (active ? push(ref, prio) : undefined), [push, active, prio])
|
||||
}
|
||||
|
||||
export const modalOpen = () => !!document.querySelector('[data-nav-scope]')
|
||||
|
|
|
|||
40
src/renderer/src/screens/Collections.tsx
Normal file
40
src/renderer/src/screens/Collections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/renderer/src/screens/GameDetail.tsx
Normal file
92
src/renderer/src/screens/GameDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/renderer/src/screens/Home.tsx
Normal file
51
src/renderer/src/screens/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/renderer/src/screens/Library.tsx
Normal file
93
src/renderer/src/screens/Library.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/renderer/src/screens/Settings.tsx
Normal file
89
src/renderer/src/screens/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/renderer/src/screens/filter.ts
Normal file
28
src/renderer/src/screens/filter.ts
Normal 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])
|
||||
}
|
||||
21
src/renderer/src/state/format.ts
Normal file
21
src/renderer/src/state/format.ts
Normal 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' }
|
||||
40
src/renderer/src/state/library.tsx
Normal file
40
src/renderer/src/state/library.tsx
Normal 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)
|
||||
42
src/renderer/src/state/router.tsx
Normal file
42
src/renderer/src/state/router.tsx
Normal 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)
|
||||
46
src/renderer/src/state/session.tsx
Normal file
46
src/renderer/src/state/session.tsx
Normal 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)
|
||||
|
|
@ -45,7 +45,8 @@ mark { background: none; color: var(--accent); }
|
|||
.topbar { position: fixed; inset: 0 0 auto 0; height: 76px; display: flex; align-items: center; gap: 28px; padding: 0 var(--pad); z-index: 20; }
|
||||
|
||||
/* Fond animé */
|
||||
.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
|
||||
.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; transition: opacity var(--dur-slow); }
|
||||
:root:not([data-route='home']) .bg-layer { opacity: .25; } /* fond discret hors de l'accueil */
|
||||
.bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Barre d'aide (boutons) */
|
||||
|
|
@ -77,3 +78,154 @@ mark { background: none; color: var(--accent); }
|
|||
.osk__key.is-on { color: var(--accent); }
|
||||
.osk__key.is-focused { box-shadow: var(--focus-ring); transform: scale(1.06); background: var(--surface-2); }
|
||||
.osk__key--ok.is-focused { background: var(--accent); }
|
||||
|
||||
/* ── Coque ── */
|
||||
.topbar__logo { font: 900 26px/1 var(--font-sans); letter-spacing: -.04em; }
|
||||
.topbar__logo span, .splash__logo span { color: var(--accent); }
|
||||
.tabs { display: flex; align-items: center; gap: 6px; margin: 0 auto; }
|
||||
.tab { padding: 10px 18px; border-radius: var(--radius-s); font: 400 14px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--muted); transition: color var(--dur-fast), background var(--dur-fast); }
|
||||
.tab.is-active { color: var(--text); background: var(--surface-2); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.topbar__end { display: flex; gap: 18px; font-size: 15px; letter-spacing: .08em; }
|
||||
.view { position: fixed; inset: 76px 0 0 0; animation: view-in var(--dur-med) var(--ease); }
|
||||
@keyframes view-in { from { opacity: 0; transform: translateY(10px); } }
|
||||
.screen { height: 100%; padding: 36px var(--pad) 90px; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||
.screen::-webkit-scrollbar { display: none; }
|
||||
.playing { position: fixed; left: var(--pad); bottom: 18px; z-index: 31; font-size: 14px; color: var(--accent); letter-spacing: .06em; }
|
||||
.small { font-size: 14px; }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 28px; }
|
||||
|
||||
/* ── Cartes : nom et favori ── */
|
||||
.card { padding: 0; width: 100%; }
|
||||
.card__name { position: absolute; inset: auto 0 0 0; padding: 30px 12px 10px; font: 600 14px/1.25 var(--font-sans); text-align: left; color: var(--text);
|
||||
background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 92%, transparent)); opacity: 0; transition: opacity var(--dur-fast); }
|
||||
.card.is-focused .card__name { opacity: 1; }
|
||||
.card__fav { position: absolute; top: 8px; right: 10px; color: var(--accent); font-size: 18px; text-shadow: 0 1px 6px rgba(0, 0, 0, .6); }
|
||||
.card--off img, .card--off .placeholder { filter: grayscale(1); opacity: .45; }
|
||||
|
||||
/* Jaquette générée (pas d'image) */
|
||||
.placeholder { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; padding: 18px; text-align: left;
|
||||
background: radial-gradient(circle at 70% 20%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%), linear-gradient(160deg, var(--surface-2), var(--surface)); }
|
||||
.placeholder__p { font: 400 11px/1 var(--font-mono); letter-spacing: .18em; text-transform: uppercase; color: var(--accent); }
|
||||
.placeholder__n { font: 900 22px/1 var(--font-sans); letter-spacing: -.03em; text-transform: uppercase; overflow-wrap: anywhere; }
|
||||
.placeholder--hero { justify-content: center; align-items: flex-end; opacity: .5; }
|
||||
|
||||
/* ── Accueil ── */
|
||||
.home { padding-top: 0; }
|
||||
.home__hero { position: relative; height: 52vh; min-height: 360px; margin: 0 calc(-1 * var(--pad)) 10px; display: flex; align-items: flex-end; padding: 0 var(--pad) 42px; }
|
||||
.home__heroBg, .detail__bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
|
||||
.home__heroBg img, .detail__bg img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.home__heroBg::after, .detail__bg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, var(--bg) 8%, color-mix(in srgb, var(--bg) 40%, transparent) 60%, transparent), linear-gradient(transparent 40%, var(--bg)); }
|
||||
.home__heroTxt { max-width: 720px; display: flex; flex-direction: column; align-items: flex-start; gap: 14px; }
|
||||
.home__heroTxt .hero-title { font-size: clamp(40px, 5vw, 84px); }
|
||||
.home__logo, .detail__logo { max-width: min(520px, 80%); max-height: 170px; object-fit: contain; object-position: left bottom; filter: drop-shadow(0 6px 24px rgba(0, 0, 0, .6)); }
|
||||
.row { margin-top: 26px; }
|
||||
.row__title { margin-bottom: 14px; }
|
||||
.row__track { display: flex; gap: 22px; overflow-x: auto; overflow-y: visible; padding: 14px 4px 22px; margin: -14px -4px 0; scrollbar-width: none; }
|
||||
.row__track::-webkit-scrollbar { display: none; }
|
||||
.row__track .card { flex: none; width: 190px; }
|
||||
.screen--empty { display: flex; flex-direction: column; justify-content: center; max-width: 900px; }
|
||||
|
||||
/* ── Bibliothèque ── */
|
||||
.library { display: flex; flex-direction: column; overflow: hidden; padding-bottom: 70px; }
|
||||
.library__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap; }
|
||||
.count { font: 400 22px/1 var(--font-mono); letter-spacing: 0; vertical-align: super; }
|
||||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.toolbar .btn { padding: 11px 16px; }
|
||||
.chips { display: flex; gap: 8px; margin: 22px 0 18px; flex-wrap: wrap; }
|
||||
.chip { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line); font: 400 13px/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
|
||||
transition: color var(--dur-fast), border-color var(--dur-fast), box-shadow var(--dur-fast); }
|
||||
.chip--on { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }
|
||||
.chip.is-focused { box-shadow: var(--focus-ring); color: var(--text); }
|
||||
.chip--on.is-focused { color: var(--on-accent); }
|
||||
.empty { margin-top: 40px; }
|
||||
.grid { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 14px 14px 40px; margin: -14px -14px 0; scrollbar-width: none; }
|
||||
.grid::-webkit-scrollbar { display: none; }
|
||||
.grid__inner { position: relative; }
|
||||
.grid__cell { position: absolute; top: 0; left: 0; }
|
||||
.list { flex: 1; overflow-y: auto; scrollbar-width: none; }
|
||||
.row-item { display: grid; grid-template-columns: 1fr 140px 140px 170px; gap: 18px; align-items: center; width: 100%; padding: 18px 6px; text-align: left; font-size: 20px; }
|
||||
.row-item__meta { font-size: 13px; color: var(--muted); }
|
||||
.carousel { display: flex; gap: 30px; align-items: center; overflow-x: auto; flex: 1; padding: 0 30vw; scrollbar-width: none; }
|
||||
.carousel .card { flex: none; width: 260px; }
|
||||
|
||||
/* ── Collections ── */
|
||||
.colls { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 22px; margin-top: 28px; }
|
||||
.coll { display: flex; flex-direction: column; gap: 8px; padding: 16px; text-align: left; border-radius: var(--radius-m); background: var(--surface); border: 1px solid var(--line);
|
||||
transition: transform var(--dur-med) var(--ease), box-shadow var(--dur-med), border-color var(--dur-med); }
|
||||
.coll.is-focused { transform: scale(1.03); box-shadow: var(--focus-ring); border-color: var(--accent); }
|
||||
.coll--new { justify-content: center; align-items: center; min-height: 200px; border-style: dashed; color: var(--muted); font: 400 15px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.coll__covers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; height: 150px; }
|
||||
.coll__covers > * { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-s); overflow: hidden; }
|
||||
.coll__covers .placeholder { padding: 8px; }
|
||||
.coll__covers .placeholder__n { font-size: 12px; }
|
||||
.coll__name { font: 800 22px/1.1 var(--font-sans); }
|
||||
|
||||
/* ── Fiche jeu ── */
|
||||
.detail { display: grid; grid-template-columns: 1fr 300px; gap: 60px; align-items: end; padding-bottom: 110px; }
|
||||
.detail__bg { position: fixed; top: 76px; }
|
||||
.detail__bg img { opacity: .9; }
|
||||
.detail__body { max-width: 860px; display: flex; flex-direction: column; align-items: flex-start; }
|
||||
.detail__name { font-size: clamp(44px, 6vw, 100px); }
|
||||
.detail__cover { width: 300px; aspect-ratio: 2 / 3; box-shadow: var(--shadow-card); }
|
||||
.stats { display: flex; gap: 40px; margin: 22px 0 0; }
|
||||
.stats dt { font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
|
||||
.stats dd { margin: 0; font: 700 22px/1 var(--font-sans); }
|
||||
.btn--play { font-size: 15px; padding: 16px 30px; }
|
||||
.genres { margin: 22px 0 6px; font-size: 13px; letter-spacing: .1em; }
|
||||
.detail__desc { max-width: 720px; color: color-mix(in srgb, var(--text) 82%, var(--muted)); font-size: 17px; }
|
||||
|
||||
/* ── Paramètres ── */
|
||||
.settings__cols { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; margin-top: 18px; }
|
||||
.settings__h { font-size: 12px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); margin: 26px 0 8px; font-weight: 400; }
|
||||
.setting { display: flex; justify-content: space-between; align-items: center; gap: 20px; width: 100%; padding: 15px 16px; border-radius: var(--radius-s); text-align: left;
|
||||
transition: background var(--dur-fast), box-shadow var(--dur-fast); }
|
||||
.setting__l { display: flex; flex-direction: column; gap: 3px; }
|
||||
.setting__l small { font-size: 13px; }
|
||||
.setting__v { color: var(--muted); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 55%; }
|
||||
.setting.is-focused { background: var(--surface-2); box-shadow: var(--focus-ring); }
|
||||
.setting.is-focused .setting__v { color: var(--accent); }
|
||||
.bar { display: inline-block; width: 140px; height: 6px; border-radius: 3px; background: var(--line); vertical-align: middle; overflow: hidden; }
|
||||
.bar span { display: block; height: 100%; background: var(--accent); }
|
||||
|
||||
/* ── Fenêtres ── */
|
||||
.modal__box { width: min(640px, 92vw); max-height: 80vh; overflow-y: auto; padding: 34px; border-radius: var(--radius-l); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-card); scrollbar-width: none; }
|
||||
.modal__box--wide { width: min(1100px, 94vw); }
|
||||
.modal__title { font-size: 34px; margin-bottom: 22px; }
|
||||
.menu { display: flex; flex-direction: column; gap: 4px; }
|
||||
.menu__item { display: flex; align-items: center; gap: 14px; padding: 15px 16px; border-radius: var(--radius-s); text-align: left; font-size: 19px; transition: background var(--dur-fast), padding-left var(--dur-med) var(--ease); }
|
||||
.menu__item.is-focused { background: var(--surface-2); box-shadow: var(--focus-ring); padding-left: 22px; }
|
||||
.menu__item--danger.is-focused { color: var(--danger); box-shadow: 0 0 0 2px var(--danger); }
|
||||
.menu__check { color: var(--accent); font-size: 14px; }
|
||||
.menu__hint { margin-left: auto; font-size: 12px; color: var(--muted); }
|
||||
.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 6px; }
|
||||
.file { padding: 12px 14px; border-radius: var(--radius-s); text-align: left; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: var(--bg); border: 1px solid transparent; }
|
||||
.file--dir { color: var(--text); }
|
||||
.file--up, .file--pick { color: var(--accent); }
|
||||
.file.is-focused { border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||
.form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; padding: 15px 16px; border-radius: var(--radius-s); background: var(--bg); text-align: left; border: 1px solid var(--line); }
|
||||
.field__l { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
|
||||
.field__v { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.field.is-focused { border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||
.seg { display: flex; gap: 6px; margin-bottom: 18px; }
|
||||
.seg__b { padding: 9px 16px; border-radius: var(--radius-s); font: 400 13px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line); }
|
||||
.seg__b.is-active { color: var(--on-accent); background: var(--accent); border-color: var(--accent); }
|
||||
.picker { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
|
||||
.picker--hero, .picker--logo { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
.picker__opt { border-radius: var(--radius-s); overflow: hidden; background: var(--bg); aspect-ratio: 2 / 3; border: 1px solid var(--line); transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast); }
|
||||
.picker--hero .picker__opt { aspect-ratio: 96 / 31; }
|
||||
.picker--logo .picker__opt, .picker--icon .picker__opt { aspect-ratio: 16 / 9; padding: 12px; }
|
||||
.picker--icon .picker__opt { aspect-ratio: 1; }
|
||||
.picker__opt img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.picker--logo img, .picker--icon img { object-fit: contain; }
|
||||
.picker__file { display: grid; place-items: center; color: var(--accent); font: 400 13px/1.3 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; padding: 12px; }
|
||||
.picker__opt.is-focused { transform: scale(1.05); box-shadow: var(--focus-ring); }
|
||||
|
||||
/* ── Démarrage ── */
|
||||
.splash { position: fixed; inset: 0; z-index: 100; background: var(--bg); display: grid; place-items: center; transition: opacity .45s var(--ease); }
|
||||
.splash canvas, .splash img, .splash video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.splash__logo { position: relative; font: 900 clamp(60px, 9vw, 150px)/1 var(--font-sans); letter-spacing: -.05em; animation: splash-in 1.1s var(--ease) both; }
|
||||
.splash.is-out { opacity: 0; }
|
||||
@keyframes splash-in { from { opacity: 0; transform: scale(.94); letter-spacing: .02em; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; } }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export interface FsEntry { name: string; path: string; dir: boolean }
|
|||
export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] }
|
||||
export type FsFilter = 'exe' | 'image' | 'dir'
|
||||
|
||||
export interface RunningGame { gameId: string; start: number; tracked: boolean }
|
||||
export type PowerAction = 'sleep' | 'restart' | 'shutdown'
|
||||
|
||||
export type MediaKind = 'cover' | 'hero' | 'logo' | 'icon'
|
||||
export interface MediaOption { url: string; thumb: string; width?: number; height?: number }
|
||||
|
||||
|
|
@ -44,6 +47,11 @@ export interface Api {
|
|||
'media:set': (gameId: string, kind: MediaKind, url: string) => Promise<Game>
|
||||
'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise<Game>
|
||||
'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:create': (name: string) => Promise<Collection>
|
||||
'collections:rename': (id: string, name: string) => Promise<void>
|
||||
|
|
@ -57,6 +65,8 @@ export interface Api {
|
|||
export interface Events {
|
||||
'library:changed': Game[]
|
||||
'scan:state': { running: boolean; report?: ScanReport }
|
||||
'session:state': { running: RunningGame | null; last?: { gameId: string; duration: number } }
|
||||
'app:front': true
|
||||
'settings:changed': Settings
|
||||
'themes:changed': { id: string; themes: ThemeEntry[] }
|
||||
}
|
||||
|
|
@ -76,6 +86,11 @@ export const CHANNELS = [
|
|||
'media:set',
|
||||
'media:setFile',
|
||||
'media:refresh',
|
||||
'game:launch',
|
||||
'game:stop',
|
||||
'session:get',
|
||||
'system:power',
|
||||
'system:quit',
|
||||
'collections:list',
|
||||
'collections:create',
|
||||
'collections:rename',
|
||||
|
|
@ -85,7 +100,7 @@ export const CHANNELS = [
|
|||
'themes:list'
|
||||
] as const satisfies readonly Channel[]
|
||||
|
||||
export const EVENTS = ['library:changed', 'scan:state', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[]
|
||||
export const EVENTS = ['library:changed', 'scan:state', 'session:state', 'app:front', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[]
|
||||
|
||||
/* Ce que l'interface voit dans window.orbe */
|
||||
export type OrbeApi = Api & {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/* Types partagés entre le processus principal et l'interface */
|
||||
|
||||
export type Platform = 'steam' | 'epic' | 'gog' | 'ubisoft' | 'ea' | 'manual'
|
||||
export type Platform = 'steam' | 'epic' | 'gog' | 'ubisoft' | 'ea' | 'folder' | 'manual'
|
||||
export type ScannerId = Exclude<Platform, 'manual'>
|
||||
export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea']
|
||||
export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea', 'folder']
|
||||
|
||||
export interface GameMedia {
|
||||
cover?: string // URL servie par orbe-media:// (cache) ou fichier choisi par l'utilisateur
|
||||
|
|
@ -63,7 +63,7 @@ export interface Settings {
|
|||
theme: string
|
||||
steamGridDbKey: string
|
||||
scanners: Record<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
|
||||
fullscreen: boolean
|
||||
kiosk: boolean
|
||||
|
|
@ -75,7 +75,7 @@ export interface Settings {
|
|||
export const DEFAULT_SETTINGS: Settings = {
|
||||
theme: 'orbe',
|
||||
steamGridDbKey: '',
|
||||
scanners: { steam: true, epic: true, gog: true, ubisoft: true, ea: true },
|
||||
scanners: { steam: true, epic: true, gog: true, ubisoft: true, ea: true, folder: true },
|
||||
extraFolders: [],
|
||||
autostart: false,
|
||||
fullscreen: true,
|
||||
|
|
|
|||
24
tests/library-ui.test.ts
Normal file
24
tests/library-ui.test.ts
Normal 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
30
tests/match.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -97,3 +97,19 @@ describe('runScanners', () => {
|
|||
expect(r.ok.gog).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
import { mainExe } from '../src/main/scanners/folder'
|
||||
|
||||
describe('dossiers de jeux', () => {
|
||||
it("prend le plus gros exe qui n'est pas un outil", () => {
|
||||
const tree: Record<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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue