diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 266544f..af58810 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,21 +1,27 @@ +import { useState } from 'react' import { Background } from './components/Background' -import { SettingsProvider, useSettings } from './state/settings' -import { ThemeProvider, useTheme } from './theme/theme' +import { Keyboard } from './components/Keyboard' +import { HintBar } from './nav/glyphs' +import { NavProvider } from './nav/nav' +import { SettingsProvider } from './state/settings' +import { ThemeProvider } from './theme/theme' import './styles/base.css' function Preview() { - const { themes, theme } = useTheme() - const { update } = useSettings() + const [kb, setKb] = useState(false) + const [text, setText] = useState('') return (
-

Thèmes

+

Navigation

Orbe Launcher

-
- {themes.map((t) => ( - - ))} +

{text || '…'}

+
+ {Array.from({ length: 12 }, (_, i) =>
+ + {kb && { setText(v); setKb(false) }} onCancel={() => setKb(false)} />} + {!kb && }
) } @@ -24,7 +30,9 @@ export function App() { return ( - + + + ) diff --git a/src/renderer/src/components/Keyboard.tsx b/src/renderer/src/components/Keyboard.tsx new file mode 100644 index 0000000..1c681b0 --- /dev/null +++ b/src/renderer/src/components/Keyboard.tsx @@ -0,0 +1,83 @@ +/* Clavier virtuel plein écran pour la manette. Le clavier physique tape aussi dedans. */ +import { useEffect, useRef, useState } from 'react' +import { HintBar } from '../nav/glyphs' +import { useNav } from '../nav/nav' +import { useSettings } from '../state/settings' +import { useT } from '../i18n/i18n' + +const LAYOUTS = { + fr: ['1234567890', 'azertyuiop', 'qsdfghjklm', "wxcvbn,.'-"], + en: ['1234567890', 'qwertyuiop', 'asdfghjkl-', "zxcvbnm,.'"] +} + +interface Props { + title?: string + value: string + max?: number + onChange?: (v: string) => void // à chaque frappe (recherche instantanée) + onSubmit: (v: string) => void + onCancel: () => void +} + +export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit, onCancel }: Props) { + const t = useT() + const { settings } = useSettings() + const [value, setValue] = useState(initial) + const [shift, setShift] = useState(false) + const first = useRef(true) + const type = (c: string) => setValue((v) => (v + c).slice(0, max)) + const typeKey = (c: string) => type(shift ? c.toUpperCase() : c) + const del = () => setValue((v) => v.slice(0, -1)) + useEffect(() => { if (first.current) first.current = false; else onChange?.(value) }, [value]) + + /* Clavier physique : caractères et retour arrière vont dans le champ, avant la navigation */ + useEffect(() => { + const k = (e: KeyboardEvent) => { + if (e.ctrlKey || e.altKey || e.metaKey) return + if (e.key.length === 1) type(e.key) + else if (e.key === 'Backspace') del() + else if (e.key === 'Enter' && !(document.activeElement instanceof HTMLButtonElement)) onSubmit(value) + else return + e.preventDefault() + e.stopImmediatePropagation() + } + addEventListener('keydown', k, true) + return () => removeEventListener('keydown', k, true) + }) + + useNav((a) => { + if (a === '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 + }) + + return ( +
+
+

{title ?? t('kb.title')}

+
{value}
+
+ {LAYOUTS[settings.language].map((row, i) => ( +
+ {[...row].map((c, j) => ( + + ))} +
+ ))} +
+ + + + +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/styles/base.css b/src/renderer/src/styles/base.css index 266ddb9..022b712 100644 --- a/src/renderer/src/styles/base.css +++ b/src/renderer/src/styles/base.css @@ -47,3 +47,33 @@ mark { background: none; color: var(--accent); } /* Fond animé */ .bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; } .bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; } + +/* Barre d'aide (boutons) */ +.hintbar { position: fixed; inset: auto 0 0 0; height: 64px; display: flex; justify-content: flex-end; align-items: center; gap: 28px; padding: 0 var(--pad); + font: 400 14px/1 var(--font-mono); color: var(--muted); letter-spacing: .06em; z-index: 30; background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 85%, transparent) 40%); } +.hint { display: inline-flex; align-items: center; gap: 10px; } +.glyph { display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface-2); color: var(--text); font: 700 13px/1 var(--font-mono); } +.glyph--xbox.glyph--accept { background: #107c10; border-color: #107c10; color: #fff; } +.glyph--xbox.glyph--back { background: #c4302b; border-color: #c4302b; color: #fff; } +.glyph--xbox.glyph--x { background: #1f6fd1; border-color: #1f6fd1; color: #fff; } +.glyph--xbox.glyph--y { background: #d6a400; border-color: #d6a400; color: #111; } +.glyph--keyboard, .glyph--mouse { border-radius: var(--radius-s); } + +/* Fenêtres modales */ +.modal { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); animation: modal-in var(--dur-med) var(--ease); } +@keyframes modal-in { from { opacity: 0; } } + +/* Clavier virtuel */ +.osk__box { width: min(1100px, 92vw); padding-bottom: 60px; } +.osk__field { min-height: 76px; padding: 18px 22px; margin-bottom: 26px; border-radius: var(--radius-m); border: 1px solid var(--accent); background: var(--surface); font-size: 30px; white-space: pre; overflow: hidden; } +.osk__caret { display: inline-block; width: 3px; height: 1.1em; margin-left: 2px; vertical-align: -.15em; background: var(--accent); } +.osk__keys { display: grid; gap: 10px; } +.osk__row { display: flex; gap: 10px; justify-content: center; } +.osk__key { flex: 1; max-width: 96px; height: 68px; border-radius: var(--radius-s); background: var(--surface); border: 1px solid var(--line); font: 600 24px/1 var(--font-sans); + transition: transform var(--dur-fast) var(--ease), background var(--dur-fast), box-shadow var(--dur-fast); } +.osk__key--wide { max-width: 150px; } +.osk__key--space { max-width: 420px; flex: 4; font-size: 16px; } +.osk__key--ok { max-width: 180px; background: var(--accent); color: var(--on-accent); font-size: 16px; } +.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); } diff --git a/src/renderer/src/theme/theme.tsx b/src/renderer/src/theme/theme.tsx index 28d5ad5..2a4b33c 100644 --- a/src/renderer/src/theme/theme.tsx +++ b/src/renderer/src/theme/theme.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import type { ThemeEntry } from '@shared/ipc' import { api } from '../api' +import { loadSounds, setVolume } from '../nav/sounds' import { useSettings } from '../state/settings' interface Ctx { @@ -46,7 +47,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return api.on('themes:changed', ({ themes }) => { setThemes(themes); setVersion(Date.now()) }) }, []) const theme = themes.find((t) => t.manifest.id === settings.theme) ?? themes.find((t) => t.manifest.id === 'orbe') ?? themes[0] ?? null - useEffect(() => { if (theme && ready) apply(theme, version) }, [theme, version, ready]) + useEffect(() => { + if (!theme || !ready) return + apply(theme, version) + loadSounds(theme.base, theme.manifest.sounds) + }, [theme, version, ready]) + useEffect(() => setVolume(settings.volume), [settings.volume]) const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined) return {children} } diff --git a/themes/orbe/sounds/back.wav b/themes/orbe/sounds/back.wav new file mode 100644 index 0000000..aec33b3 Binary files /dev/null and b/themes/orbe/sounds/back.wav differ diff --git a/themes/orbe/sounds/error.wav b/themes/orbe/sounds/error.wav new file mode 100644 index 0000000..8966202 Binary files /dev/null and b/themes/orbe/sounds/error.wav differ diff --git a/themes/orbe/sounds/launch.wav b/themes/orbe/sounds/launch.wav new file mode 100644 index 0000000..c1b95fe Binary files /dev/null and b/themes/orbe/sounds/launch.wav differ diff --git a/themes/orbe/sounds/move.wav b/themes/orbe/sounds/move.wav new file mode 100644 index 0000000..19e594e Binary files /dev/null and b/themes/orbe/sounds/move.wav differ diff --git a/themes/orbe/sounds/select.wav b/themes/orbe/sounds/select.wav new file mode 100644 index 0000000..7774f47 Binary files /dev/null and b/themes/orbe/sounds/select.wav differ diff --git a/themes/orbe/theme.json b/themes/orbe/theme.json index e327f86..bf3de45 100644 --- a/themes/orbe/theme.json +++ b/themes/orbe/theme.json @@ -28,14 +28,47 @@ "--ease": "cubic-bezier(.2, .8, .2, 1)" }, "fonts": [ - { "family": "Archivo", "src": "fonts/Archivo-100-900-latin.woff2", "weight": "100 900", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" }, - { "family": "Archivo", "src": "fonts/Archivo-100-900-latin-ext.woff2", "weight": "100 900", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" }, - { "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin.woff2", "weight": "400 700", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" }, - { "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin-ext.woff2", "weight": "400 700", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" } + { + "family": "Archivo", + "src": "fonts/Archivo-100-900-latin.woff2", + "weight": "100 900", + "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" + }, + { + "family": "Archivo", + "src": "fonts/Archivo-100-900-latin-ext.woff2", + "weight": "100 900", + "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" + }, + { + "family": "JetBrains Mono", + "src": "fonts/JetBrainsMono-400-latin.woff2", + "weight": "400 700", + "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" + }, + { + "family": "JetBrains Mono", + "src": "fonts/JetBrainsMono-400-latin-ext.woff2", + "weight": "400 700", + "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" + } ], "css": "theme.css", - "layout": { "library": "grid" }, - "background": { "type": "orb" }, - "splash": { "type": "orb", "duration": 1800 }, - "sounds": {} + "layout": { + "library": "grid" + }, + "background": { + "type": "orb" + }, + "splash": { + "type": "orb", + "duration": 1800 + }, + "sounds": { + "move": "sounds/move.wav", + "select": "sounds/select.wav", + "back": "sounds/back.wav", + "error": "sounds/error.wav", + "launch": "sounds/launch.wav" + } } diff --git a/tools/gen-sounds.mjs b/tools/gen-sounds.mjs new file mode 100644 index 0000000..516a234 --- /dev/null +++ b/tools/gen-sounds.mjs @@ -0,0 +1,49 @@ +// Génère les sons de navigation du thème Orbe (WAV 16 bits mono, 44,1 kHz) : sobres, courts, sans dépendance. +// Usage : node tools/gen-sounds.mjs +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const RATE = 44100 +const out = join(import.meta.dirname, '..', 'themes', 'orbe', 'sounds') + +/* voix : fréquence qui glisse de f0 à f1, enveloppe attaque/déclin, timbre sinus + un peu d'harmonique */ +function tone(dur, f0, f1, { gain = 0.5, attack = 0.004, harm = 0.15, delay = 0 } = {}) { + const n = Math.round((dur + delay) * RATE), s = new Float32Array(n) + let phase = 0 + for (let i = Math.round(delay * RATE); i < n; i++) { + const t = i / RATE - delay, p = t / dur + const f = f0 * Math.pow(f1 / f0, p) + phase += (2 * Math.PI * f) / RATE + const env = Math.min(1, t / attack) * Math.pow(1 - p, 2.2) + s[i] = gain * env * (Math.sin(phase) + harm * Math.sin(2 * phase)) + } + return s +} + +const mix = (...parts) => { + const n = Math.max(...parts.map((p) => p.length)), s = new Float32Array(n) + for (const p of parts) for (let i = 0; i < p.length; i++) s[i] += p[i] + return s +} + +function wav(samples) { + const b = Buffer.alloc(44 + samples.length * 2) + b.write('RIFF', 0); b.writeUInt32LE(36 + samples.length * 2, 4); b.write('WAVE', 8) + b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22) + b.writeUInt32LE(RATE, 24); b.writeUInt32LE(RATE * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34) + b.write('data', 36); b.writeUInt32LE(samples.length * 2, 40) + samples.forEach((v, i) => b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), 44 + i * 2)) + return b +} + +const sounds = { + move: tone(0.035, 2400, 2200, { gain: 0.18, harm: 0 }), // petit tic + select: mix(tone(0.07, 660, 680, { gain: 0.32 }), tone(0.09, 990, 1000, { gain: 0.28, delay: 0.045 })), // deux notes qui montent + back: mix(tone(0.07, 740, 720, { gain: 0.28 }), tone(0.09, 494, 480, { gain: 0.26, delay: 0.045 })), // deux notes qui descendent + error: mix(tone(0.08, 180, 170, { gain: 0.35, harm: 0.4 }), tone(0.08, 180, 170, { gain: 0.35, harm: 0.4, delay: 0.11 })), + launch: mix(tone(0.45, 330, 1320, { gain: 0.3, attack: 0.02 }), tone(0.5, 495, 1980, { gain: 0.12, attack: 0.05, delay: 0.06 })) +} + +mkdirSync(out, { recursive: true }) +for (const [name, s] of Object.entries(sounds)) writeFileSync(join(out, name + '.wav'), wav(s)) +console.log('sons écrits dans', out)