fix: refonte mode PTT continu avec activation par drag vertical

CORRECTIONS :
- Fix son qui ne restait pas actif en mode lock (utilisation de ref au lieu de state)
- Les event handlers utilisent isLockModeRef.current pour accès immédiat

NOUVELLE ACTIVATION :
- Remplacement appui long 3s par drag vertical (glisser vers le haut)
- Drag de 80px vers le haut active le mode lock
- Indicateur visuel avec flèche + texte "Glissez pour verrouiller"
- Bouton suit le doigt pendant le drag (transform translateY)
- Feedback haptique à l'activation (triple vibration)

DÉSACTIVATION :
- Tap/clic sur le bouton en mode lock désactive le mode
- Feedback haptique simple à la désactivation

UX AMÉLIORÉE :
- Plus intuitif qu'un appui long
- Retour visuel immédiat du drag
- Compatible touch (mobile) et mouse (desktop)

🤖 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:43:49 +02:00
parent 0b0e998b55
commit 49df6bd44c
2 changed files with 214 additions and 114 deletions
+31 -11
View File
@@ -8,6 +8,7 @@
justify-content: center; justify-content: center;
padding: var(--spacing-xl); padding: var(--spacing-xl);
gap: var(--spacing-lg); gap: var(--spacing-lg);
position: relative;
} }
.ptt-button { .ptt-button {
@@ -22,7 +23,7 @@
justify-content: center; justify-content: center;
gap: var(--spacing-md); gap: var(--spacing-md);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: transform 0.1s ease, box-shadow 0.2s ease, background 0.2s ease;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -121,17 +122,36 @@
line-height: 1.4; line-height: 1.4;
} }
/* Indicateur de progression pour mode lock */ /* Indicateur de drag vers le haut */
.lock-progress { .drag-indicator {
position: absolute; position: absolute;
bottom: 0; top: -60px;
left: 0; left: 50%;
height: 6px; transform: translateX(-50%);
background: linear-gradient(90deg, #fbbf24, #f59e0b); display: flex;
border-radius: 0 0 0 120px; flex-direction: column;
transition: width 0.05s linear; align-items: center;
box-shadow: 0 0 20px rgba(251, 191, 36, 0.6); gap: 0.5rem;
z-index: 1; color: #fbbf24;
font-size: 0.875rem;
font-weight: 600;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 10;
animation: drag-pulse 0.6s ease-in-out infinite;
}
.drag-indicator svg {
filter: drop-shadow(0 2px 8px rgba(251, 191, 36, 0.6));
}
@keyframes drag-pulse {
0%, 100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-5px);
}
} }
/* Badge mode lock */ /* Badge mode lock */
+183 -103
View File
@@ -4,15 +4,20 @@ 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) * Modes :
* Activation mode continu : appui long 3s * - PTT classique : maintenir pour parler
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle
*/ */
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 [isLockMode, setIsLockMode] = useState(false);
const [lockProgress, setLockProgress] = useState(0); const isLockModeRef = useRef(false); // Ref pour accès immédiat dans event handlers
// Drag tracking
const dragStartYRef = useRef(null);
const currentYRef = useRef(null);
const [dragOffset, setDragOffset] = useState(0); // Offset visuel du drag (en pixels)
useEffect(() => { useEffect(() => {
const button = buttonRef.current; const button = buttonRef.current;
@@ -23,163 +28,240 @@ 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'); const touch = e.touches[0];
console.log('🖐️ Touch start at Y:', touch.clientY);
if (isLockMode) { // En mode lock, un tap désactive le mode
// En mode lock, un tap désactive le mode if (isLockModeRef.current) {
toggleLockMode(); toggleLockMode();
} else { return;
// Mode PTT normal : démarrer }
if (!isPressingRef.current) {
isPressingRef.current = true; // Mode PTT normal : démarrer + init drag
onPressStart(); if (!isPressingRef.current) {
// Démarrer timer pour mode lock isPressingRef.current = true;
startLongPressTimer(); dragStartYRef.current = touch.clientY;
} currentYRef.current = touch.clientY;
onPressStart();
}
};
const handleTouchMove = (e) => {
e.preventDefault();
// Pas de drag en mode lock
if (isLockModeRef.current || !isPressingRef.current) {
return;
}
const touch = e.touches[0];
currentYRef.current = touch.clientY;
// Calculer le déplacement vertical (négatif = vers le haut)
const deltaY = dragStartYRef.current - touch.clientY;
// Limiter le drag vers le haut (max 100px)
const offset = Math.max(0, Math.min(100, deltaY));
setDragOffset(offset);
console.log('📏 Drag offset:', offset);
// Si on a glissé de 80px vers le haut, activer le mode lock
if (offset >= 80) {
activateLockMode();
} }
}; };
const handleTouchEnd = (e) => { const handleTouchEnd = (e) => {
e.preventDefault(); e.preventDefault();
console.log('🖐️ Touch end'); console.log('🖐️ Touch end, dragOffset:', dragOffset);
if (!isLockMode) { // Réinitialiser le drag
// Mode PTT normal : arrêter dragStartYRef.current = null;
if (isPressingRef.current) { currentYRef.current = null;
isPressingRef.current = false; setDragOffset(0);
onPressEnd();
// Annuler timer si pas encore 3s // En mode lock, ne rien faire (le micro reste actif)
cancelLongPressTimer(); if (isLockModeRef.current) {
} return;
}
// Mode PTT normal : arrêter
if (isPressingRef.current) {
isPressingRef.current = false;
onPressEnd();
} }
}; };
// Mouse events (desktop) // Mouse events (desktop)
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
e.preventDefault(); e.preventDefault();
console.log('🖱️ Mouse down at Y:', e.clientY);
if (isLockMode) { // En mode lock, un clic désactive le mode
if (isLockModeRef.current) {
toggleLockMode(); toggleLockMode();
} else { return;
if (!isPressingRef.current) { }
isPressingRef.current = true;
onPressStart(); if (!isPressingRef.current) {
startLongPressTimer(); isPressingRef.current = true;
} dragStartYRef.current = e.clientY;
currentYRef.current = e.clientY;
onPressStart();
}
};
const handleMouseMove = (e) => {
// Pas de drag en mode lock
if (isLockModeRef.current || !isPressingRef.current) {
return;
}
currentYRef.current = e.clientY;
// Calculer le déplacement vertical (négatif = vers le haut)
const deltaY = dragStartYRef.current - e.clientY;
// Limiter le drag vers le haut (max 100px)
const offset = Math.max(0, Math.min(100, deltaY));
setDragOffset(offset);
// Si on a glissé de 80px vers le haut, activer le mode lock
if (offset >= 80) {
activateLockMode();
} }
}; };
const handleMouseUp = (e) => { const handleMouseUp = (e) => {
e.preventDefault(); e.preventDefault();
console.log('🖱️ Mouse up, dragOffset:', dragOffset);
if (!isLockMode) { // Réinitialiser le drag
if (isPressingRef.current) { dragStartYRef.current = null;
isPressingRef.current = false; currentYRef.current = null;
onPressEnd(); setDragOffset(0);
cancelLongPressTimer();
} // En mode lock, ne rien faire
if (isLockModeRef.current) {
return;
}
if (isPressingRef.current) {
isPressingRef.current = false;
onPressEnd();
} }
}; };
const handleMouseLeave = (e) => { const handleMouseLeave = (e) => {
// Si on quitte le bouton en maintenant, on arrête (sauf en mode lock) // Si on quitte le bouton en maintenant, on arrête (sauf en mode lock)
if (!isLockMode && isPressingRef.current) { if (!isLockModeRef.current && isPressingRef.current) {
// Réinitialiser le drag
dragStartYRef.current = null;
currentYRef.current = null;
setDragOffset(0);
isPressingRef.current = false; isPressingRef.current = false;
onPressEnd(); onPressEnd();
cancelLongPressTimer();
} }
}; };
// Attacher events // Attacher events
button.addEventListener('touchstart', handleTouchStart, { passive: false }); button.addEventListener('touchstart', handleTouchStart, { passive: false });
button.addEventListener('touchmove', handleTouchMove, { passive: false });
button.addEventListener('touchend', handleTouchEnd, { passive: false }); button.addEventListener('touchend', handleTouchEnd, { passive: false });
button.addEventListener('touchcancel', handleTouchEnd, { passive: false }); button.addEventListener('touchcancel', handleTouchEnd, { passive: false });
button.addEventListener('mousedown', handleMouseDown); button.addEventListener('mousedown', handleMouseDown);
button.addEventListener('mousemove', handleMouseMove);
button.addEventListener('mouseup', handleMouseUp); button.addEventListener('mouseup', handleMouseUp);
button.addEventListener('mouseleave', handleMouseLeave); button.addEventListener('mouseleave', handleMouseLeave);
button.addEventListener('contextmenu', preventDefault); button.addEventListener('contextmenu', preventDefault);
return () => { return () => {
button.removeEventListener('touchstart', handleTouchStart); button.removeEventListener('touchstart', handleTouchStart);
button.removeEventListener('touchmove', handleTouchMove);
button.removeEventListener('touchend', handleTouchEnd); button.removeEventListener('touchend', handleTouchEnd);
button.removeEventListener('touchcancel', handleTouchEnd); button.removeEventListener('touchcancel', handleTouchEnd);
button.removeEventListener('mousedown', handleMouseDown); button.removeEventListener('mousedown', handleMouseDown);
button.removeEventListener('mousemove', handleMouseMove);
button.removeEventListener('mouseup', handleMouseUp); button.removeEventListener('mouseup', handleMouseUp);
button.removeEventListener('mouseleave', handleMouseLeave); button.removeEventListener('mouseleave', handleMouseLeave);
button.removeEventListener('contextmenu', preventDefault); button.removeEventListener('contextmenu', preventDefault);
}; };
}, [onPressStart, onPressEnd]); }, [onPressStart, onPressEnd]);
// Fonction pour activer le mode lock
const activateLockMode = () => {
console.log('🔒 Mode lock activé par drag');
setIsLockMode(true);
isLockModeRef.current = true;
// Réinitialiser le drag
setDragOffset(0);
dragStartYRef.current = null;
currentYRef.current = null;
// Le micro est déjà actif (onPressStart a été appelé)
// Vibration pour feedback
if (navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
};
// Fonction pour basculer le mode lock (appelée par le toggle externe)
const toggleLockMode = () => {
const newLockMode = !isLockModeRef.current;
console.log('🔄 Toggle lock mode:', isLockModeRef.current, '→', newLockMode);
setIsLockMode(newLockMode);
isLockModeRef.current = newLockMode;
if (newLockMode) {
// Activer le mode lock : démarrer l'audio
console.log('🔒 Mode lock ON');
onPressStart();
// Vibration pour feedback
if (navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
} else {
// Désactiver le mode lock : couper l'audio
console.log('🔓 Mode lock OFF');
onPressEnd();
// Vibration pour feedback
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
};
return ( return (
<div className="ptt-container"> <div className="ptt-container">
{/* Zone de drag vers le haut (indicateur visuel) */}
{dragOffset > 0 && !isLockMode && (
<div className="drag-indicator" style={{ opacity: dragOffset / 80 }}>
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32">
<path d="M7 14l5-5 5 5H7z"/>
</svg>
<span>Glissez pour verrouiller</span>
</div>
)}
{/* Bouton PTT principal */}
<button <button
ref={buttonRef} ref={buttonRef}
className={`ptt-button ${isTalking ? 'talking' : ''} ${isLockMode ? 'locked' : ''}`} className={`ptt-button ${isTalking ? 'talking' : ''} ${isLockMode ? 'locked' : ''}`}
type="button" type="button"
style={{
transform: dragOffset > 0 && !isLockMode ? `translateY(-${dragOffset * 0.3}px)` : 'none'
}}
> >
{/* 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 ? (
@@ -218,10 +300,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
{isLockMode {isLockMode
? 'Tapez pour désactiver le mode continu' ? 'Tapez pour désactiver le mode continu'
: isTalking : isTalking
? lockProgress > 0 ? 'Glissez vers le haut pour verrouiller • Relâchez pour arrêter'
? 'Maintenez 3s pour mode continu...' : 'Appuyez et maintenez pour parler • Glissez vers le haut pour verrouiller'}
: 'Relâchez pour arrêter'
: 'Appuyez et maintenez le bouton • Maintenez 3s pour mode continu'}
</p> </p>
</div> </div>
); );