feat: mode PTT continu avec activation par appui long (Phase 2.2)

- Nouveau mode PTT continu (lock) activé par appui long de 3s
- Barre de progression visuelle pendant l'appui (0-100%)
- Badge cadenas en haut à droite quand mode actif
- Animation pulsante distinctive pour mode lock
- Feedback haptique à l'activation (triple vibration)
- Désactivation par simple tap quand mode lock actif
- Indication textuelle claire de l'état (normal/lock)
- Styles responsifs mobile + accessibilité (prefers-reduced-motion)

Mode d'emploi :
- Normal : Maintenir pour parler, relâcher pour arrêter
- Lock : Maintenir 3s → mode continu → tap pour désactiver

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-23 09:34:30 +02:00
parent 3181c62e57
commit 78e9a32e12
2 changed files with 211 additions and 19 deletions
+81 -1
View File
@@ -60,6 +60,14 @@
animation: pulse-talking 1.5s ease-in-out infinite; animation: pulse-talking 1.5s ease-in-out infinite;
} }
/* État: Mode lock (continu) */
.ptt-button.locked {
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.6),
0 0 80px rgba(220, 38, 38, 0.4);
animation: pulse-locked 2s ease-in-out infinite;
}
@keyframes pulse-talking { @keyframes pulse-talking {
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
@@ -69,6 +77,19 @@
} }
} }
@keyframes pulse-locked {
0%, 100% {
transform: scale(1);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.6),
0 0 80px rgba(220, 38, 38, 0.4);
}
50% {
transform: scale(1.03);
box-shadow: 0 8px 40px rgba(220, 38, 38, 0.7),
0 0 100px rgba(220, 38, 38, 0.5);
}
}
/* Icône micro */ /* Icône micro */
.ptt-icon { .ptt-icon {
width: 64px; width: 64px;
@@ -96,6 +117,49 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
text-align: center; text-align: center;
max-width: 90%;
line-height: 1.4;
}
/* Indicateur de progression pour mode lock */
.lock-progress {
position: absolute;
bottom: 0;
left: 0;
height: 6px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
border-radius: 0 0 0 120px;
transition: width 0.05s linear;
box-shadow: 0 0 20px rgba(251, 191, 36, 0.6);
z-index: 1;
}
/* Badge mode lock */
.lock-badge {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
animation: lock-badge-pulse 1s ease-in-out infinite;
z-index: 2;
}
@keyframes lock-badge-pulse {
0%, 100% {
transform: scale(1);
background: rgba(255, 255, 255, 0.2);
}
50% {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.3);
}
} }
/* Responsive mobile */ /* Responsive mobile */
@@ -140,11 +204,27 @@
} }
} }
/* Responsive mobile - badge lock */
@media (max-width: 640px) {
.lock-badge {
top: 15px;
right: 15px;
width: 36px;
height: 36px;
}
}
/* Accessibilité : désactiver effets réduits */ /* Accessibilité : désactiver effets réduits */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ptt-button, .ptt-button,
.ptt-button.talking { .ptt-button.talking,
.ptt-button.locked,
.lock-badge {
animation: none; animation: none;
transition: none; transition: none;
} }
.lock-progress {
transition: none;
}
} }
+130 -18
View File
@@ -1,13 +1,18 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import './PTTButton.css'; import './PTTButton.css';
/** /**
* Bouton PTT principal * Bouton PTT principal
* Gère touch et mouse events pour desktop et mobile * Gère touch et mouse events pour desktop et mobile
* Modes : PTT classique (maintenir) ou mode continu (toggle)
* Activation mode continu : appui long 3s
*/ */
export default function PTTButton({ isTalking, onPressStart, onPressEnd }) { export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
const buttonRef = useRef(null); const buttonRef = useRef(null);
const isPressingRef = useRef(false); const isPressingRef = useRef(false);
const longPressTimerRef = useRef(null);
const [isLockMode, setIsLockMode] = useState(false);
const [lockProgress, setLockProgress] = useState(0);
useEffect(() => { useEffect(() => {
const button = buttonRef.current; const button = buttonRef.current;
@@ -18,47 +23,126 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
e.preventDefault(); e.preventDefault();
}; };
// Démarrer timer pour mode lock
const startLongPressTimer = () => {
// Animation de progression (0 → 100 en 3s)
const duration = 3000;
const startTime = Date.now();
const updateProgress = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(100, (elapsed / duration) * 100);
setLockProgress(progress);
if (elapsed >= duration) {
// Mode lock activé
activateLockMode();
} else {
longPressTimerRef.current = requestAnimationFrame(updateProgress);
}
};
longPressTimerRef.current = requestAnimationFrame(updateProgress);
};
const cancelLongPressTimer = () => {
if (longPressTimerRef.current) {
cancelAnimationFrame(longPressTimerRef.current);
longPressTimerRef.current = null;
}
setLockProgress(0);
};
const activateLockMode = () => {
console.log('🔒 Mode lock activé');
setIsLockMode(true);
cancelLongPressTimer();
// Vibration longue pour feedback
if (navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
};
const toggleLockMode = () => {
if (isLockMode) {
// Désactiver mode lock
console.log('🔓 Mode lock désactivé');
setIsLockMode(false);
onPressEnd();
} else {
// En mode normal, un clic simple ne fait rien
// (il faut maintenir ou activer le mode lock)
}
};
// Touch events (mobile) // Touch events (mobile)
const handleTouchStart = (e) => { const handleTouchStart = (e) => {
e.preventDefault(); e.preventDefault();
console.log('🖐️ Touch start'); console.log('🖐️ Touch start');
if (!isPressingRef.current) {
isPressingRef.current = true; if (isLockMode) {
onPressStart(); // En mode lock, un tap désactive le mode
toggleLockMode();
} else {
// Mode PTT normal : démarrer
if (!isPressingRef.current) {
isPressingRef.current = true;
onPressStart();
// Démarrer timer pour mode lock
startLongPressTimer();
}
} }
}; };
const handleTouchEnd = (e) => { const handleTouchEnd = (e) => {
e.preventDefault(); e.preventDefault();
console.log('🖐️ Touch end'); console.log('🖐️ Touch end');
if (isPressingRef.current) {
isPressingRef.current = false; if (!isLockMode) {
onPressEnd(); // Mode PTT normal : arrêter
if (isPressingRef.current) {
isPressingRef.current = false;
onPressEnd();
// Annuler timer si pas encore 3s
cancelLongPressTimer();
}
} }
}; };
// Mouse events (desktop) // Mouse events (desktop)
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
e.preventDefault(); e.preventDefault();
if (!isPressingRef.current) {
isPressingRef.current = true; if (isLockMode) {
onPressStart(); toggleLockMode();
} else {
if (!isPressingRef.current) {
isPressingRef.current = true;
onPressStart();
startLongPressTimer();
}
} }
}; };
const handleMouseUp = (e) => { const handleMouseUp = (e) => {
e.preventDefault(); e.preventDefault();
if (isPressingRef.current) {
isPressingRef.current = false; if (!isLockMode) {
onPressEnd(); if (isPressingRef.current) {
isPressingRef.current = false;
onPressEnd();
cancelLongPressTimer();
}
} }
}; };
const handleMouseLeave = (e) => { const handleMouseLeave = (e) => {
// Si on quitte le bouton en maintenant, on arrête // Si on quitte le bouton en maintenant, on arrête (sauf en mode lock)
if (isPressingRef.current) { if (!isLockMode && isPressingRef.current) {
isPressingRef.current = false; isPressingRef.current = false;
onPressEnd(); onPressEnd();
cancelLongPressTimer();
} }
}; };
@@ -86,9 +170,17 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
<div className="ptt-container"> <div className="ptt-container">
<button <button
ref={buttonRef} ref={buttonRef}
className={`ptt-button ${isTalking ? 'talking' : ''}`} className={`ptt-button ${isTalking ? 'talking' : ''} ${isLockMode ? 'locked' : ''}`}
type="button" type="button"
> >
{/* Indicateur de progression pour mode lock */}
{lockProgress > 0 && !isLockMode && (
<div
className="lock-progress"
style={{ width: `${lockProgress}%` }}
/>
)}
<div className="ptt-icon"> <div className="ptt-icon">
{isTalking ? ( {isTalking ? (
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
@@ -103,13 +195,33 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
</svg> </svg>
)} )}
</div> </div>
{/* Badge mode lock */}
{isLockMode && (
<div className="lock-badge">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"/>
</svg>
</div>
)}
<span className="ptt-label"> <span className="ptt-label">
{isTalking ? 'En cours...' : 'Maintenir pour parler'} {isLockMode
? 'Mode continu actif'
: isTalking
? 'En cours...'
: 'Maintenir pour parler'}
</span> </span>
</button> </button>
<p className="ptt-hint"> <p className="ptt-hint">
{isTalking ? 'Relâchez pour arrêter' : 'Appuyez et maintenez le bouton'} {isLockMode
? 'Tapez pour désactiver le mode continu'
: isTalking
? lockProgress > 0
? 'Maintenez 3s pour mode continu...'
: 'Relâchez pour arrêter'
: 'Appuyez et maintenez le bouton • Maintenez 3s pour mode continu'}
</p> </p>
</div> </div>
); );