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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (isLockMode) {
|
||||||
|
// En mode lock, un tap désactive le mode
|
||||||
|
toggleLockMode();
|
||||||
|
} else {
|
||||||
|
// Mode PTT normal : démarrer
|
||||||
if (!isPressingRef.current) {
|
if (!isPressingRef.current) {
|
||||||
isPressingRef.current = true;
|
isPressingRef.current = true;
|
||||||
onPressStart();
|
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 (!isLockMode) {
|
||||||
|
// Mode PTT normal : arrêter
|
||||||
if (isPressingRef.current) {
|
if (isPressingRef.current) {
|
||||||
isPressingRef.current = false;
|
isPressingRef.current = false;
|
||||||
onPressEnd();
|
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 (isLockMode) {
|
||||||
|
toggleLockMode();
|
||||||
|
} else {
|
||||||
if (!isPressingRef.current) {
|
if (!isPressingRef.current) {
|
||||||
isPressingRef.current = true;
|
isPressingRef.current = true;
|
||||||
onPressStart();
|
onPressStart();
|
||||||
|
startLongPressTimer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (e) => {
|
const handleMouseUp = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!isLockMode) {
|
||||||
if (isPressingRef.current) {
|
if (isPressingRef.current) {
|
||||||
isPressingRef.current = false;
|
isPressingRef.current = false;
|
||||||
onPressEnd();
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user