Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ sont conservés en changeant de type.
| Afficheur | Affiche en direct une valeur de SimHub ou de MSFS (vitesse, rapport, temps au tour…) ; un appui peut déclencher une action |
| Bouton rotatif | Tourner (glisser le doigt) pour « + » / « − », appuyer pour valider ; peut afficher une valeur du simulateur |
| Curseur | Glisser pour régler une position (gaz, volets…) ou envoyer des crans « + » / « − » |
| Interrupteur à N positions | Interrupteur à 3 positions, sélecteur à 8 positions… (2 à 12) : une action par position, un appui passe à la suivante (aller-retour ou en boucle), position lue dans le simulateur, visuel par position |
| Bascule (2 états) | Alterne entre deux états (ex. train rentré / sorti) : titre, icône, couleur et action propres à chaque état |

<p align="center">
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "streamsim",
"productName": "StreamSim",
"version": "0.12.10",
"version": "0.12.11",
"description": "Clone logiciel de Stream Deck : serveur PC, application de configuration et client Android.",
"author": "StreamSim",
"license": "MIT",
Expand Down
11 changes: 11 additions & 0 deletions public/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ <h2 id="actions">3. Les types d'action</h2>
<tr><td>Page</td><td>Change la page du Deck</td></tr>
<tr><td><a href="#a-multi">Multi-actions</a></td><td>Enchaîne plusieurs actions, avec des pauses</td></tr>
<tr><td><a href="#a-bascule">Bascule</a></td><td>Deux états (allumé/éteint), avec un aspect par état</td></tr>
<tr><td><a href="#a-interrupteur">Interrupteur à N positions</a></td><td>Interrupteur à 3 positions, sélecteur à 8 positions… : un appui passe à la suivante</td></tr>
<tr><td><a href="#a-rotatif">Bouton rotatif</a></td><td>Glisser = « + » / « − », appui = valider, avec affichage d'une valeur</td></tr>
<tr><td><a href="#a-curseur">Curseur</a></td><td>Position réglable (gaz, volets…) ou crans</td></tr>
<tr><td><a href="#a-afficheur">Afficheur</a></td><td>Valeur en direct d'un simulateur</td></tr>
Expand Down Expand Up @@ -227,6 +228,16 @@ <h3 id="a-bascule">Bascule (deux états)</h3>
<li>Sans synchronisation, si l'état se désynchronise, un <b>appui long</b> sur la touche change l'état <i>sans rien envoyer</i> (le bouton « Changer d'état » de l'inspecteur fait pareil).</li>
</ul>

<h3 id="a-interrupteur">Interrupteur à N positions</h3>
<p>Pour un interrupteur à <b>trois positions</b> (haut / milieu / bas), un sélecteur à <b>huit positions</b> ou tout autre commutateur de 2 à 12 positions. Chaque position a <b>sa propre action</b> (raccourci, commande MSFS, variable <code>L:</code>, SimHub…) ; à chaque appui, la touche envoie l'action de la position suivante.</p>
<ul>
<li><b>Parcours</b> : <i>Aller-retour</i> (haut → milieu → bas → milieu → haut…) ou <i>En boucle</i> (1 → 2 → 3 → 1…).</li>
<li><b>MobiFlight</b> : saisissez la variable <code>L:</code> de l'interrupteur et cliquez sur <i>Remplir les positions</i> : chaque position fixe la variable à 0, 1, 2… et la touche lit sa position dans cette même variable. Vous pouvez ensuite modifier chaque position à la main.</li>
<li><b>Position lue dans le simulateur</b> (MSFS, MobiFlight ou SimHub) : la touche suit la position <i>réelle</i>, même si vous manœuvrez l'interrupteur dans le cockpit. Si les valeurs lues ne sont pas 0, 1, 2… (par exemple 0, 50, 100), indiquez-les dans « Valeurs lues par position ».</li>
<li><b>Visuel par position</b> : choisissez une série fournie (levier de côté, bascule de face, glissière : 3 positions ; sélecteur rotatif : 8 positions). Sans visuel, la touche affiche son titre et des repères de position.</li>
<li>Sans lecture dans le simulateur, la position est gardée par le PC et partagée par tous les appareils.</li>
</ul>

<h3 id="a-rotatif">Bouton rotatif</h3>
<figure class="shot-fig"><img class="shot" src="/public/docs-img/action-rotatif.jpg" alt="Inspecteur : action Bouton rotatif, avec Tourner + réglé sur Droite et Tourner - réglé sur Gauche"><figcaption>Exemple : « Tourner + » → <code>Droite</code>, « Tourner − » → <code>Gauche</code> (deux raccourcis clavier, mais n'importe quel type d'action convient).</figcaption></figure>
<p>Un bouton rotatif se manipule en glissant le doigt (ou la molette de la souris sur PC) :</p>
Expand Down
107 changes: 107 additions & 0 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from './catalog.js';
import { computeCells, placementError, findFreeSlot, keySpan, stateKey } from '/shared/layout.js';
import { SIMHUB_PROPERTIES } from '/shared/simhub.js';
import { switchCount } from '/shared/controls.js';
import { AVIATION_ICON_GROUPS, aviationIcon, SWITCH_FACE_GROUPS, switchFaces } from '/shared/icons.js';
import { library, refreshIcons, uploadIconFile, openIconLibrary, exportIcons, pickAndImportIcons } from './icons.js';
import { MSFS_EVENTS, MSFS_EVENT_LABELS, MSFS_SIMVARS, MSFS_NUMERIC_SIMVARS, MSFS_UNITS, FBW_EVENTS, FBW_PRESETS, isLocalVar } from '/shared/msfs.js';
Expand Down Expand Up @@ -772,6 +773,8 @@ function actionFields(getAction, tag) {
return [multiEditor(getAction, tag)];
case 'toggle':
return [toggleEditor(getAction, tag)];
case 'switch':
return [switchEditor(getAction, tag)];
case 'msfs':
return msfsFields(getAction, tag);
case 'simhub':
Expand Down Expand Up @@ -1710,6 +1713,110 @@ function sizeField(i) {
const INNER_TYPES = ['msfs', 'simhub', 'hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi'];

// Éditeur d'une touche à bascule : une action par état (ou la même pour les deux).
// Interrupteur à N positions : une action par position, lecture facultative de la position réelle.
function switchEditor(getAction, tag) {
const a = getAction();
const n = switchCount(a);
const SRC = ['simvar', 'input', 'simhub'];
// La liste des positions suit le nombre choisi (les actions déjà saisies sont conservées).
if (!Array.isArray(a.positions)) a.positions = [];
while (a.positions.length < n) a.positions.push(ACTION_TYPES.hotkey.create());
a.positions.length = n;
const d = a.display ?? {};
const synced = SRC.some((k) => k in d);
const matching = SWITCH_FACE_GROUPS.filter((g) => g.positions.length === n);
const mode = a.mode === 'cycle' ? 'cycle' : 'bounce';
const setCount = (v) =>
commit(() => {
const c = switchCount({ count: v });
const act = getAction();
act.count = c;
while (act.positions.length < c) act.positions.push(ACTION_TYPES.hotkey.create());
act.positions.length = c;
if (Array.isArray(act.display?.images)) act.display.images.length = Math.min(act.display.images.length, c);
if (Array.isArray(act.display?.values) && act.display.values.length !== c) delete act.display.values;
});
const posKey = h('input', { type: 'text', placeholder: 'ex. AZP_RAF_SELECTOR (variable L: MobiFlight)' });
const fillFromVar = () => {
const raw = posKey.value.trim();
if (!raw) return toast('Saisissez le nom de la variable.', 'err');
const name = /^[A-Za-z]:/.test(raw) ? raw : `L:${raw}`;
commit(() => {
const act = getAction();
act.positions = Array.from({ length: switchCount(act) }, (_, i) => ({ type: 'msfs', kind: 'var', var: name, unit: 'number', op: 'set', value: i }));
act.display = { ...(act.display ?? {}), simvar: name, unit: 'number' };
delete act.display.input;
delete act.display.simhub;
delete act.display.values;
});
};
return h(
'div',
{ class: 'field' },
h('div', { class: 'note info' }, icon('info'),
h('span', {}, 'Chaque appui envoie l’action de la position suivante : en aller-retour (haut → milieu → bas → milieu…) ou en boucle (1 → 2 → 3 → 1…). Si vous liez une valeur du simulateur, la touche suit la position réelle de l’interrupteur, même s’il est manœuvré dans le cockpit.')),
h('div', { class: 'row' },
h('label', { class: 'field' }, h('span', {}, 'Nombre de positions (2 à 12)'),
h('input', { type: 'number', min: 2, max: 12, value: n, oninput: (e) => e.target.value !== '' && setCount(e.target.value) })),
h('div', { class: 'field' }, h('span', { class: 'field-label' }, 'Parcours'),
h('div', { class: 'segmented' },
...[['bounce', 'Aller-retour'], ['cycle', 'En boucle']].map(([id, label]) =>
h('button', { class: mode === id ? 'on' : '', onclick: () => commit(() => (getAction().mode = id)) }, label))))),
h('div', { class: 'field' },
h('span', { class: 'field-label' }, 'Raccourci : variable L: (MobiFlight)'),
h('div', { class: 'row' }, posKey,
h('button', { class: 'btn small', style: { flex: 'none' }, onclick: fillFromVar, title: 'Position i : variable = i, et position lue dans la même variable' }, 'Remplir les positions')),
h('span', { class: 'hint' }, 'Remplit chaque position avec « variable = 0, 1, 2… » et lit la position dans cette variable. Vous pouvez ensuite modifier chaque position à la main.')),
h('div', { class: 'steps' }, ...a.positions.map((_, i) => innerActionCard(`Position ${i + 1}`, () => getAction().positions, i, `${tag}:p${i}`))),
h('div', { class: 'field sim-sync' },
h('label', { class: 'switch' },
h('input', {
type: 'checkbox',
checked: synced,
onchange: (e) => commit(() => {
const act = getAction();
act.display ??= {};
if (e.target.checked) Object.assign(act.display, { simvar: 'L:', unit: 'number' });
else for (const k of [...SRC, 'unit', 'values']) delete act.display[k];
}),
}),
'Lire la position dans le simulateur (MSFS, MobiFlight ou SimHub)'),
...(synced
? [
...simSourceFields(() => getAction().display, `${tag}:display`, { unitDefault: 'number' }),
h('label', { class: 'field' }, h('span', {}, 'Valeurs lues par position (facultatif)'),
h('input', {
type: 'text',
value: Array.isArray(d.values) ? d.values.join(', ') : '',
placeholder: `vide = 0, 1, 2… ; sinon ${n} valeurs, ex. 0, 50, 100`,
oninput: (e) => commit(() => {
const vals = e.target.value.split(/[;,]/).map((x) => x.trim()).filter(Boolean).map(Number);
const act = getAction();
if (vals.length === switchCount(act) && vals.every((v) => !Number.isNaN(v))) act.display.values = vals;
else delete act.display.values;
}, { tag: `${tag}:values`, render: 'key' }),
})),
]
: []),
),
h('div', { class: 'field' },
h('span', { class: 'field-label' }, 'Visuel par position'),
matching.length
? h('div', { class: 'row', style: { flexWrap: 'wrap' } },
...matching.map((g) => h('button', {
class: 'btn small',
onclick: () => commit(() => {
const act = getAction();
act.display ??= {};
act.display.images = switchFaces(g);
}),
}, g.label)),
Array.isArray(d.images) && d.images.length ? h('button', { class: 'btn small danger', onclick: () => commit(() => delete getAction().display.images) }, 'Retirer les visuels') : null)
: h('span', { class: 'hint' }, 'Aucun visuel fourni pour ce nombre de positions (3 ou 8 : levier, bascule, glissière, sélecteur rotatif).'),
h('span', { class: 'hint' }, 'Sans visuel, la touche affiche son titre et des repères de position.')),
);
}

function toggleEditor(getAction, tag) {
const a = getAction();
const i = state.selected;
Expand Down
40 changes: 38 additions & 2 deletions public/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { formatHotkey, MEDIA_ACTIONS } from '/shared/keys.js';
import { h } from './dom.js';
import { faceFor } from '/shared/layout.js';
import { MSFS_PRESETS, MSFS_EVENT_LABELS, MSFS_DIAL_PRESETS, MSFS_SLIDER_PRESETS, FBW_PRESETS, RAFALE_PRESETS, RAFALE_COCKPIT_PRESETS } from '/shared/msfs.js';
import { formatDisplay } from '/shared/controls.js';
import { formatDisplay, switchCount, positionOf } from '/shared/controls.js';
import { SIMHUB_PRESETS } from '/shared/simhub.js';
import { aviationIcon, isFacePath, isUserIconPath, isBuiltinIconPath } from '/shared/icons.js';

Expand Down Expand Up @@ -207,6 +207,22 @@ export const ACTION_TYPES = {
return a.same ? `2 états · ${sum(a0)}` : `${sum(a0)} ⇄ ${sum(a1)}`;
},
},
switch: {
label: 'Interrupteur',
long: 'Interrupteur à N positions',
desc: 'Chaque appui passe à la position suivante (3 positions, 8 positions…)',
icon: '🎚️',
color: '#f97316',
create: () => ({
type: 'switch',
count: 3,
mode: 'bounce',
positions: [0, 1, 2].map(() => ({ type: 'hotkey', hotkey: { modifiers: [], key: '' }, target: { by: 'none', value: '' } })),
display: null,
}),
face: { icon: '🎚️', color: '#334155', title: 'Interrupteur' },
summary: (a) => `${switchCount(a)} positions · ${a.mode === 'cycle' ? 'cycle' : 'aller-retour'}${a.display?.simvar || a.display?.input || a.display?.simhub ? ' · position lue dans le simulateur' : ''}`,
},
multi: {
label: 'Multi',
long: 'Multi-actions',
Expand Down Expand Up @@ -256,7 +272,7 @@ export const LIBRARY = [
},
{
group: 'Avancé',
items: [{ type: 'multi' }, { type: 'toggle' }, { type: 'dial' }, { type: 'slider' }],
items: [{ type: 'multi' }, { type: 'toggle' }, { type: 'switch' }, { type: 'dial' }, { type: 'slider' }],
},
{
group: 'MSFS 2024 (SimConnect)',
Expand Down Expand Up @@ -428,6 +444,25 @@ function dialFace(key, live) {
return el;
}

// Interrupteur à N positions : un visuel par position s'il y en a, sinon libellé et repères de position.
function switchFace(key, live) {
const a = key.action;
const n = switchCount(a);
const d = a.display ?? {};
const pos = positionOf(live.value ?? d.start ?? 0, d.values, n);
if (Array.isArray(d.images) && d.images[pos]) {
return h('div', { class: 'keyface kf-full-key' }, h('img', { class: 'kf-icon kf-full', src: d.images[pos], alt: '', draggable: 'false' }));
}
const el = h('div', { class: 'keyface' });
el.style.setProperty('--key-color', key.color || '#334155');
const ic = iconNode(key.icon, 'kf-icon');
if (ic) el.append(ic);
const label = Array.isArray(d.labels) && d.labels[pos] ? d.labels[pos] : key.title;
if (key.showTitle !== false && label) el.append(h('span', { class: 'kf-title' }, label));
el.append(h('span', { class: 'kf-state', title: `Position ${pos + 1} sur ${n}` }, ...Array.from({ length: n }, (_, i) => h('i', { class: i === pos ? 'on' : '' }))));
return el;
}

// Afficheur : grande valeur en direct, titre en dessous.
function displayFace(key, live) {
const el = h('div', { class: 'keyface kf-display-key' });
Expand Down Expand Up @@ -467,6 +502,7 @@ export function keyFace(rawKey, state = 0, live = {}) {
if (rawKey.action?.type === 'dial') return dialFace(rawKey, live);
if (rawKey.action?.type === 'slider') return sliderFace(rawKey, live);
if (rawKey.action?.type === 'display') return displayFace(rawKey, live);
if (rawKey.action?.type === 'switch') return switchFace(rawKey, live);
const key = faceFor(rawKey, state);
if (isFacePath(key.icon)) return h('div', { class: 'keyface kf-full-key' }, h('img', { class: 'kf-icon kf-full', src: key.icon, alt: '', draggable: 'false' }));
const showTitle = key.showTitle !== false && key.title;
Expand Down
4 changes: 2 additions & 2 deletions public/js/deck.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,11 @@ function buildKey(pg, cell) {
dataset: { index: i },
style: { gridColumn: `${cell.col + 1} / span ${cell.w}`, gridRow: `${cell.row + 1} / span ${cell.h}` },
},
keyFace(key, toggleState(pg.id, i), key?.action?.type === 'display' ? liveOf(pg.id, i, cell) : undefined),
keyFace(key, toggleState(pg.id, i), ['display', 'switch'].includes(key?.action?.type) ? liveOf(pg.id, i, cell) : undefined),
);
if (!key) return el;
// Afficheur : redessiné à chaque nouvelle valeur reçue.
if (key.action?.type === 'display') el.repaint = () => el.querySelector('.keyface')?.replaceWith(keyFace(key, 0, liveOf(pg.id, i, cell)));
if (['display', 'switch'].includes(key.action?.type)) el.repaint = () => el.querySelector('.keyface')?.replaceWith(keyFace(key, 0, liveOf(pg.id, i, cell)));

const isToggle = key.action?.type === 'toggle';
let timer = null;
Expand Down
8 changes: 7 additions & 1 deletion server/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Types d'action exécutés côté serveur. Les actions de navigation ("page")
// sont gérées par la surface Deck elle-même.
export const SERVER_ACTIONS = ['hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi', 'delay', 'toggle', 'msfs', 'simhub', 'dial', 'slider', 'display'];
export const SERVER_ACTIONS = ['hotkey', 'text', 'media', 'launch', 'url', 'command', 'multi', 'delay', 'toggle', 'msfs', 'simhub', 'dial', 'slider', 'display', 'switch'];

/**
* Touche à bascule : action envoyée selon l'état courant (0 = état 1, 1 = état 2).
Expand Down Expand Up @@ -121,6 +121,12 @@ export async function runAction(executor, action, depth = 0, ctx = {}) {
if (inner.type === 'toggle') throw new Error('Une bascule ne peut pas en contenir une autre.');
return runAction(executor, inner, depth + 1, ctx);
}
case 'switch': {
// Bouton « Tester » : envoie l'action de la position demandée (la première par défaut).
const inner = action.positions?.[action.testPos ?? 0];
if (!inner?.type) throw new Error('Aucune action définie pour cette position.');
return runAction(executor, inner, depth + 1, ctx);
}
case 'dial':
// Bouton « Tester » : on simule un appui (ou un cran « + » s'il n'y a pas d'action d'appui).
if (action.press?.type) return runAction(executor, action.press, depth + 1, ctx);
Expand Down
Loading
Loading