Auto commit: 2026-03-23T19:22:21.572Z
This commit is contained in:
parent
16b7d41fc5
commit
e713c1471c
19
app.php
19
app.php
@ -27,7 +27,6 @@ function ensure_recliner_schema(): void
|
||||
angle_deg TINYINT UNSIGNED NOT NULL,
|
||||
intensity_pct TINYINT UNSIGNED NOT NULL,
|
||||
pattern_mode VARCHAR(20) NOT NULL,
|
||||
duration_ms SMALLINT UNSIGNED NOT NULL,
|
||||
notes VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
@ -41,7 +40,6 @@ function default_preset(): array
|
||||
'angle_deg' => 112,
|
||||
'intensity_pct' => 44,
|
||||
'pattern_mode' => 'continuous',
|
||||
'duration_ms' => 1400,
|
||||
'notes' => 'Balanced demo profile for a calm showroom preview.',
|
||||
];
|
||||
}
|
||||
@ -69,13 +67,6 @@ function validate_preset_input(array $input): array
|
||||
$errors['intensity_pct'] = 'Intensity must be between 0% and 100%.';
|
||||
}
|
||||
|
||||
$duration = filter_var($input['duration_ms'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 100, 'max_range' => 5000],
|
||||
]);
|
||||
if ($duration === false) {
|
||||
$errors['duration_ms'] = 'Duration must be between 100 ms and 5000 ms.';
|
||||
}
|
||||
|
||||
$pattern = (string) ($input['pattern_mode'] ?? 'continuous');
|
||||
if (!in_array($pattern, RECLINER_PATTERNS, true)) {
|
||||
$errors['pattern_mode'] = 'Choose a supported vibration pattern.';
|
||||
@ -93,7 +84,6 @@ function validate_preset_input(array $input): array
|
||||
'angle_deg' => $angle === false ? 0 : (int) $angle,
|
||||
'intensity_pct' => $intensity === false ? 0 : (int) $intensity,
|
||||
'pattern_mode' => $pattern,
|
||||
'duration_ms' => $duration === false ? 1000 : (int) $duration,
|
||||
'notes' => $notes,
|
||||
],
|
||||
];
|
||||
@ -102,15 +92,14 @@ function validate_preset_input(array $input): array
|
||||
function save_preset(array $data): int
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes)
|
||||
VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :duration_ms, :notes)'
|
||||
'INSERT INTO recliner_presets (name, angle_deg, intensity_pct, pattern_mode, notes)
|
||||
VALUES (:name, :angle_deg, :intensity_pct, :pattern_mode, :notes)'
|
||||
);
|
||||
|
||||
$stmt->bindValue(':name', $data['name'], PDO::PARAM_STR);
|
||||
$stmt->bindValue(':angle_deg', $data['angle_deg'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':intensity_pct', $data['intensity_pct'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':pattern_mode', $data['pattern_mode'], PDO::PARAM_STR);
|
||||
$stmt->bindValue(':duration_ms', $data['duration_ms'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':notes', $data['notes'] !== '' ? $data['notes'] : null, $data['notes'] !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
|
||||
$stmt->execute();
|
||||
|
||||
@ -121,7 +110,7 @@ function get_recent_presets(int $limit = 8): array
|
||||
{
|
||||
$limit = max(1, min(24, $limit));
|
||||
$stmt = db()->query(
|
||||
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at
|
||||
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, notes, created_at
|
||||
FROM recliner_presets
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ' . $limit
|
||||
@ -133,7 +122,7 @@ function get_recent_presets(int $limit = 8): array
|
||||
function get_preset(int $id): ?array
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, duration_ms, notes, created_at
|
||||
'SELECT id, name, angle_deg, intensity_pct, pattern_mode, notes, created_at
|
||||
FROM recliner_presets
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
|
||||
@ -8,7 +8,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
angle: document.getElementById('angle_deg'),
|
||||
intensity: document.getElementById('intensity_pct'),
|
||||
pattern: document.getElementById('pattern_mode'),
|
||||
duration: document.getElementById('duration_ms'),
|
||||
name: document.getElementById('name'),
|
||||
notes: document.getElementById('notes')
|
||||
};
|
||||
@ -18,19 +17,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
angleValue: document.getElementById('angle-value'),
|
||||
anglePill: document.getElementById('angle-pill'),
|
||||
intensityPill: document.getElementById('intensity-pill'),
|
||||
durationPill: document.getElementById('duration-pill'),
|
||||
statAngle: document.getElementById('stat-angle'),
|
||||
statIntensity: document.getElementById('stat-intensity'),
|
||||
statPattern: document.getElementById('stat-pattern'),
|
||||
statDuration: document.getElementById('stat-duration'),
|
||||
saveAngle: document.getElementById('save-angle'),
|
||||
saveIntensity: document.getElementById('save-intensity'),
|
||||
savePattern: document.getElementById('save-pattern'),
|
||||
saveDuration: document.getElementById('save-duration'),
|
||||
summaryAngle: document.getElementById('summary-angle'),
|
||||
summaryIntensity: document.getElementById('summary-intensity'),
|
||||
summaryPattern: document.getElementById('summary-pattern'),
|
||||
summaryTone: document.getElementById('summary-tone'),
|
||||
reclineMode: document.getElementById('recline-mode'),
|
||||
gamepadState: document.getElementById('gamepad-state'),
|
||||
gamepadSelect: document.getElementById('gamepad-select'),
|
||||
@ -40,6 +35,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
let knownGamepads = [];
|
||||
let scanTimer = null;
|
||||
let continuousVibrationInterval = null;
|
||||
|
||||
const notify = (message) => {
|
||||
if (!message) {
|
||||
@ -63,8 +59,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentState = () => ({
|
||||
angle: Number(controls.angle?.value || 0),
|
||||
intensity: Number(controls.intensity?.value || 0),
|
||||
pattern: controls.pattern?.value || 'continuous',
|
||||
duration: Number(controls.duration?.value || 1000)
|
||||
pattern: controls.pattern?.value || 'continuous'
|
||||
});
|
||||
|
||||
const updateVisualization = () => {
|
||||
@ -79,19 +74,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
angleValue: `${state.angle}`,
|
||||
anglePill: `${state.angle}°`,
|
||||
intensityPill: `${state.intensity}%`,
|
||||
durationPill: `${state.duration} ms`,
|
||||
statAngle: `${state.angle}°`,
|
||||
statIntensity: `${state.intensity}%`,
|
||||
statPattern: capitalize(state.pattern),
|
||||
statDuration: `${state.duration} ms`,
|
||||
saveAngle: `${state.angle}°`,
|
||||
saveIntensity: `${state.intensity}%`,
|
||||
savePattern: capitalize(state.pattern),
|
||||
saveDuration: `${state.duration} ms`,
|
||||
summaryAngle: `${state.angle}°`,
|
||||
summaryIntensity: `${state.intensity}%`,
|
||||
summaryPattern: capitalize(state.pattern),
|
||||
summaryTone: tone,
|
||||
reclineMode: tone
|
||||
};
|
||||
|
||||
@ -109,7 +100,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg;
|
||||
if (controls.intensity && preset.intensity_pct !== undefined) controls.intensity.value = preset.intensity_pct;
|
||||
if (controls.pattern && preset.pattern_mode) controls.pattern.value = preset.pattern_mode;
|
||||
if (controls.duration && preset.duration_ms !== undefined) controls.duration.value = preset.duration_ms;
|
||||
if (controls.name && preset.name !== undefined) controls.name.value = preset.name;
|
||||
if (controls.notes && preset.notes !== undefined) controls.notes.value = preset.notes;
|
||||
updateVisualization();
|
||||
@ -211,32 +201,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { intensity, duration, pattern } = currentState();
|
||||
const { intensity, pattern } = currentState();
|
||||
const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
|
||||
ui.testButton.disabled = true;
|
||||
ui.testButton.textContent = 'Testing…';
|
||||
|
||||
if (continuousVibrationInterval) {
|
||||
window.clearInterval(continuousVibrationInterval);
|
||||
continuousVibrationInterval = null;
|
||||
ui.testButton.textContent = 'Test vibration (continuous)';
|
||||
notify('Vibration stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.testButton.textContent = 'Stop vibration';
|
||||
|
||||
try {
|
||||
if (pattern === 'pulse') {
|
||||
let remaining = duration;
|
||||
while (remaining > 0) {
|
||||
const burst = Math.min(160, remaining);
|
||||
await playEffect(actuator, normalizedIntensity, burst);
|
||||
remaining -= burst;
|
||||
if (remaining > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
}
|
||||
}
|
||||
const pulse = async () => {
|
||||
await playEffect(actuator, normalizedIntensity, 500);
|
||||
};
|
||||
pulse();
|
||||
continuousVibrationInterval = window.setInterval(pulse, 800);
|
||||
} else {
|
||||
await playEffect(actuator, normalizedIntensity, duration);
|
||||
// For continuous, we re-trigger every 100ms
|
||||
await playEffect(actuator, normalizedIntensity, 150);
|
||||
continuousVibrationInterval = window.setInterval(async () => {
|
||||
await playEffect(actuator, normalizedIntensity, 150);
|
||||
}, 100);
|
||||
}
|
||||
notify(`Running ${pattern} vibration at ${intensity}% for ${duration} ms.`);
|
||||
notify(`Running ${pattern} vibration at ${intensity}%.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notify('The browser detected the controller, but vibration could not start.');
|
||||
} finally {
|
||||
ui.testButton.textContent = 'Test vibration';
|
||||
refreshGamepads();
|
||||
ui.testButton.textContent = 'Test vibration (continuous)';
|
||||
}
|
||||
};
|
||||
|
||||
@ -248,7 +244,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
angle_deg: button.dataset.angle || 0,
|
||||
intensity_pct: button.dataset.intensity || 0,
|
||||
pattern_mode: button.dataset.pattern || 'continuous',
|
||||
duration_ms: button.dataset.duration || 1000,
|
||||
notes: button.dataset.notes || ''
|
||||
});
|
||||
window.location.hash = 'simulator';
|
||||
@ -268,7 +263,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
[controls.angle, controls.intensity, controls.pattern, controls.duration].forEach((element) => {
|
||||
[controls.angle, controls.intensity, controls.pattern].forEach((element) => {
|
||||
if (element) {
|
||||
element.addEventListener('input', updateVisualization);
|
||||
element.addEventListener('change', updateVisualization);
|
||||
@ -306,5 +301,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (scanTimer) {
|
||||
window.clearInterval(scanTimer);
|
||||
}
|
||||
if (continuousVibrationInterval) {
|
||||
window.clearInterval(continuousVibrationInterval);
|
||||
}
|
||||
});
|
||||
});
|
||||
24
index.php
24
index.php
@ -36,7 +36,6 @@ if ($presetId) {
|
||||
'angle_deg' => (int) $selectedPreset['angle_deg'],
|
||||
'intensity_pct' => (int) $selectedPreset['intensity_pct'],
|
||||
'pattern_mode' => (string) $selectedPreset['pattern_mode'],
|
||||
'duration_ms' => (int) $selectedPreset['duration_ms'],
|
||||
'notes' => (string) ($selectedPreset['notes'] ?? ''),
|
||||
];
|
||||
}
|
||||
@ -125,7 +124,6 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
<div><span>Angle</span><strong id="summary-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||
<div><span>Intensity</span><strong id="summary-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||
<div><span>Pattern</span><strong id="summary-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||
<div><span>Tone</span><strong id="summary-tone"><?= e(preset_tone((int) $formData['angle_deg'])) ?></strong></div>
|
||||
</div>
|
||||
<p class="small text-secondary mb-0 mt-3">Tip: the browser requires a user interaction before triggering vibration. Press <strong>Test vibration</strong> after connecting the controller.</p>
|
||||
</div>
|
||||
@ -164,18 +162,15 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
</div>
|
||||
|
||||
<div class="row g-3 stat-row">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="mini-stat"><span>Recline</span><strong id="stat-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="mini-stat"><span>Rumble</span><strong id="stat-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="mini-stat"><span>Pattern</span><strong id="stat-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="mini-stat"><span>Duration</span><strong id="stat-duration"><?= e((string) $formData['duration_ms']) ?> ms</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -197,7 +192,7 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label d-block">Action</label>
|
||||
<button type="button" class="btn btn-accent w-100" id="test-vibration">Test vibration</button>
|
||||
<button type="button" class="btn btn-accent w-100" id="test-vibration">Test vibration (continuous)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -210,19 +205,13 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-2"><label for="intensity_pct" class="form-label mb-0">Vibration intensity</label><span class="value-pill" id="intensity-pill"><?= e((string) $formData['intensity_pct']) ?>%</span></div>
|
||||
<input type="range" class="form-range" min="0" max="100" step="1" id="intensity_pct" name="intensity_pct" value="<?= e((string) $formData['intensity_pct']) ?>">
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="control-group">
|
||||
<label for="pattern_mode" class="form-label">Pattern</label>
|
||||
<select class="form-select" id="pattern_mode" name="pattern_mode">
|
||||
<option value="continuous" <?= $formData['pattern_mode'] === 'continuous' ? 'selected' : '' ?>>Continuous</option>
|
||||
<option value="pulse" <?= $formData['pattern_mode'] === 'pulse' ? 'selected' : '' ?>>Pulse</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2"><label for="duration_ms" class="form-label mb-0">Duration</label><span class="value-pill" id="duration-pill"><?= e((string) $formData['duration_ms']) ?> ms</span></div>
|
||||
<input type="range" class="form-range mt-3" min="100" max="5000" step="100" id="duration_ms" name="duration_ms" value="<?= e((string) $formData['duration_ms']) ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@ -261,7 +250,6 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
<div><span>Angle</span><strong id="save-angle"><?= e((string) $formData['angle_deg']) ?>°</strong></div>
|
||||
<div><span>Intensity</span><strong id="save-intensity"><?= e((string) $formData['intensity_pct']) ?>%</strong></div>
|
||||
<div><span>Pattern</span><strong id="save-pattern"><?= e(ucfirst($formData['pattern_mode'])) ?></strong></div>
|
||||
<div><span>Duration</span><strong id="save-duration"><?= e((string) $formData['duration_ms']) ?> ms</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -295,7 +283,6 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
<div class="preset-metrics mb-3">
|
||||
<span><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
|
||||
<span><?= e(ucfirst((string) $preset['pattern_mode'])) ?></span>
|
||||
<span><?= e((string) $preset['duration_ms']) ?> ms</span>
|
||||
</div>
|
||||
<?php if (!empty($preset['notes'])): ?>
|
||||
<p class="text-secondary small mb-3"><?= e($preset['notes']) ?></p>
|
||||
@ -309,7 +296,6 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
||||
data-angle="<?= e((string) $preset['angle_deg']) ?>"
|
||||
data-intensity="<?= e((string) $preset['intensity_pct']) ?>"
|
||||
data-pattern="<?= e((string) $preset['pattern_mode']) ?>"
|
||||
data-duration="<?= e((string) $preset['duration_ms']) ?>"
|
||||
data-notes="<?= e((string) ($preset['notes'] ?? '')) ?>"
|
||||
>Apply in simulator</button>
|
||||
<a class="btn btn-sm btn-link text-decoration-none px-0" href="/preset.php?id=<?= e((string) $preset['id']) ?>">View detail</a>
|
||||
|
||||
@ -64,14 +64,13 @@ $pageDescription = $preset
|
||||
<span class="chip"><?= e((string) $preset['angle_deg']) ?>° angle</span>
|
||||
<span class="chip"><?= e((string) $preset['intensity_pct']) ?>% intensity</span>
|
||||
<span class="chip"><?= e(ucfirst((string) $preset['pattern_mode'])) ?> rumble</span>
|
||||
<span class="chip"><?= e((string) $preset['duration_ms']) ?> ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="panel inset-panel p-3 h-100">
|
||||
<div class="small-label mb-3">Profile tone</div>
|
||||
<div class="h4 mb-2"><?= e(preset_tone((int) $preset['angle_deg'])) ?></div>
|
||||
<p class="text-secondary small mb-0">Saved on <?= e(date('F j, Y H:i', strtotime((string) $preset['created_at']))) ?> UTC.</p>
|
||||
<p class="text-secondary small mb-0">Saved on <?= e(date('F j, Y H:i', strtotime((string) $preset['created_at']))) ?> UTC.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,7 +84,6 @@ $pageDescription = $preset
|
||||
<div><span>Recline angle</span><strong><?= e((string) $preset['angle_deg']) ?>°</strong></div>
|
||||
<div><span>Vibration intensity</span><strong><?= e((string) $preset['intensity_pct']) ?>%</strong></div>
|
||||
<div><span>Pattern</span><strong><?= e(ucfirst((string) $preset['pattern_mode'])) ?></strong></div>
|
||||
<div><span>Duration</span><strong><?= e((string) $preset['duration_ms']) ?> ms</strong></div>
|
||||
<div><span>Saved at</span><strong><?= e((string) $preset['created_at']) ?></strong></div>
|
||||
</div>
|
||||
<hr class="border-secondary-subtle my-4">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user