Cours Vibe coding avec l'IA · Module 9: Clone de Discord
Salons vocaux et Rich Presence
Dans cette leçon5
Avant un abordage, personne n'a le temps d'écrire dans le chat : l'équipage doit se parler en direct, et le capitaine veut voir qui est sur le pont et ce que chacun fait. Discord règle ça avec des salons vocaux où l'on entre comme dans une cabine, et avec la présence enrichie (rich presence), une activité de jeu visible par toute la guilde.
L'état de la connexion vocale
Chaque personne dans un salon vocal a un état vocal, décrit par le type VoiceState :
1interface VoiceState {
2 userId: string;
3 channelId: string;
4 serverId: string;
5
6 isMuted: boolean; // micro coupé
7 isDeafened: boolean; // son coupé
8 isSelfMuted: boolean;
9 isSelfDeafened: boolean;
10 isSuppressed: boolean; // rendu muet par le serveur
11
12 isStreaming: boolean; // partage d'écran
13 isVideo: boolean; // caméra activée
14
15 sessionId: string;
16 joinedAt: Date;
17}
18
19interface VoiceChannel extends Channel {
20 type: 'voice';
21 userLimit?: number; // 0 = illimité
22 bitrate: number; // kbps
23
24 connectedUsers: VoiceState[];
25}VoiceState contient entre autres isMuted, isDeafened, isStreaming, isVideo et channelId. isMuted coupe le micro, donc les autres ne t'entendent plus, et isDeafened coupe le son, donc tu n'entends plus les autres. Les champs avec Self désignent une coupure faite par l'utilisateur lui-même, et isSuppressed une coupure imposée par le serveur. Dans un salon vocal, userLimit?: number est une limite optionnelle de personnes, et 0 signifie sans limite.
Comment on rejoint un salon
Rejoindre se fait en quatre étapes : clic sur Rejoindre le vocal, connexion WebRTC, ajout de l'utilisateur au tableau connectedUsers et affichage des commandes vocales (couper le micro, couper le son). WebRTC est la technologie des navigateurs pour transmettre le son et l'image en temps réel. Avant que quoi que ce soit ne passe, le navigateur doit obtenir l'accès au micro :
1const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
2const [microphone] = stream.getAudioTracks();
3
4microphone.enabled = false; // micro coupé : les autres entendent du silencegetUserMedia demande l'autorisation à l'utilisateur et ne marche que sur les pages HTTPS ou sur localhost. Une piste désactivée envoie du silence, et la connexion reste ouverte. Dans les grands salons, la conversation ne passe pas directement d'un navigateur à l'autre mais par un serveur intermédiaire, et Discord fonctionne aussi ainsi.
La vue du salon vocal
Le composant affiche un en-tête avec le nombre de personnes, la liste des connectés et les commandes :
1'use client';
2
3import { useState } from 'react';
4
5interface VoiceChannelViewProps {
6 channel: VoiceChannel;
7 currentUserId: string;
8 onJoin: () => void;
9 onLeave: () => void;
10 onToggleMute: () => void;
11 onToggleDeafen: () => void;
12}
13
14export default function VoiceChannelView({
15 channel,
16 currentUserId,
17 onJoin,
18 onLeave,
19 onToggleMute,
20 onToggleDeafen
21}: VoiceChannelViewProps) {
22 const currentUserVoice = channel.connectedUsers.find(v => v.userId === currentUserId);
23 const isConnected = !!currentUserVoice;
24
25 return (
26 <div className="bg-gray-700 rounded-lg p-4">
27 {/* En-tête du salon */}
28 <div className="flex items-center justify-between mb-4">
29 <div className="flex items-center gap-2">
30 <span className="text-sm font-semibold">Son</span>
31 <div>
32 <h3 className="font-bold text-white">{channel.name}</h3>
33 <p className="text-xs text-gray-400">
34 {channel.connectedUsers.length}
35 {channel.userLimit ? ` / ${channel.userLimit}` : ''} connectés
36 </p>
37 </div>
38 </div>
39
40 {!isConnected ? (
41 <button
42 onClick={onJoin}
43 disabled={!!channel.userLimit && channel.connectedUsers.length >= channel.userLimit}
44 className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded font-semibold"
45 >
46 Rejoindre le vocal
47 </button>
48 ) : (
49 <button
50 onClick={onLeave}
51 className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
52 >
53 Se déconnecter
54 </button>
55 )}
56 </div>
57
58 {/* Utilisateurs connectés */}
59 <div className="space-y-2">
60 {channel.connectedUsers.map(voice => (
61 <div
62 key={voice.userId}
63 className={`flex items-center gap-3 p-2 rounded ${
64 voice.userId === currentUserId ? 'bg-gray-600' : 'bg-gray-800'
65 }`}
66 >
67 <div className="relative">
68 <img
69 src={`/users/${voice.userId}/avatar.jpg`}
70 alt="Avatar de l'utilisateur"
71 className={`w-10 h-10 rounded-full ${
72 voice.isSpeaking ? 'ring-2 ring-green-500' : ''
73 }`}
74 />
75 {voice.isStreaming && (
76 <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center">
77 <span className="text-xs">TV</span>
78 </div>
79 )}
80 {voice.isVideo && (
81 <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
82 <span className="text-xs">Vidéo</span>
83 </div>
84 )}
85 </div>
86
87 <div className="flex-1 min-w-0">
88 <div className="text-white text-sm font-medium truncate">
89 {voice.username}
90 {voice.userId === currentUserId && (
91 <span className="text-gray-400"> (Toi)</span>
92 )}
93 </div>
94 </div>
95
96 {/* Icônes de statut */}
97 <div className="flex items-center gap-1 text-gray-400">
98 {voice.isMuted || voice.isSelfMuted ? (
99 <span className="text-red-500">Muet</span>
100 ) : (
101 <span>Micro</span>
102 )}
103 {voice.isDeafened || voice.isSelfDeafened ? (
104 <span className="text-red-500">Muet</span>
105 ) : (
106 <span>Casque</span>
107 )}
108 </div>
109 </div>
110 ))}
111 </div>
112
113 {/* Commandes vocales (si connecté) */}
114 {isConnected && (
115 <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
116 <button
117 onClick={onToggleMute}
118 className={`p-3 rounded-full ${
119 currentUserVoice.isSelfMuted
120 ? 'bg-red-600 hover:bg-red-700'
121 : 'bg-gray-600 hover:bg-gray-500'
122 }`}
123 title={currentUserVoice.isSelfMuted ? 'Réactiver le micro' : 'Couper le micro'}
124 >
125 {currentUserVoice.isSelfMuted ? 'Muet' : 'Micro'}
126 </button>
127
128 <button
129 onClick={onToggleDeafen}
130 className={`p-3 rounded-full ${
131 currentUserVoice.isSelfDeafened
132 ? 'bg-red-600 hover:bg-red-700'
133 : 'bg-gray-600 hover:bg-gray-500'
134 }`}
135 title={currentUserVoice.isSelfDeafened ? 'Réactiver le son' : 'Couper le son'}
136 >
137 {currentUserVoice.isSelfDeafened ? 'Muet' : 'Casque'}
138 </button>
139
140 <button
141 className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
142 title="Partage d'écran"
143 >
144 TV
145 </button>
146
147 <button
148 className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
149 title="Vidéo"
150 >
151 Vidéo
152 </button>
153 </div>
154 )}
155 </div>
156 );
157}Le bouton Rejoindre le vocal est désactivé quand le salon a atteint userLimit. Une personne qui parle (isSpeaking) reçoit un anneau vert autour de l'avatar grâce aux classes ring-2 ring-green-500. Les commandes n'apparaissent qu'après la connexion. La vue lit aussi voice.isSpeaking et voice.username, absents du type VoiceState : dans un vrai projet, ajoute-les au type, sinon TypeScript signalera une erreur.
La présence enrichie
La présence enrichie, c'est l'affichage de l'activité de jeu avec détails, horodatages et infos de groupe :
1interface Activity {
2 type: 'playing' | 'streaming' | 'listening' | 'watching' | 'custom';
3 name: string; // "Sea of Thieves"
4
5 // Spécifique au jeu
6 details?: string; // "In a Battle"
7 state?: string; // "4 players"
8 timestamps?: {
9 start?: number; // horodatage Unix
10 end?: number;
11 };
12
13 // Ressources (images)
14 assets?: {
15 largeImage?: string;
16 largeText?: string;
17 smallImage?: string;
18 smallText?: string;
19 };
20
21 // Infos de groupe
22 party?: {
23 id: string;
24 size: [number, number]; // [actuel, max]
25 };
26
27 // Pour le streaming
28 url?: string; // URL Twitch/YouTube
29}
30
31interface UserPresence {
32 userId: string;
33 status: 'online' | 'idle' | 'dnd' | 'offline';
34 activities: Activity[];
35 customStatus?: {
36 text: string;
37 emoji?: string;
38 expiresAt?: Date;
39 };
40}Le champ type de l'interface Activity prend les valeurs playing, streaming, listening, watching et custom, et l'API de Discord a aussi competing. party.size est un tableau [current, max] : le nombre actuel et le nombre maximum de joueurs. L'heure de début se lit avec activity.timestamps.start, en millisecondes depuis 1970. customStatus dans UserPresence contient un texte, un emoji optionnel et une date d'expiration, expiresAt.
L'activité et le profil du joueur
ActivityDisplay transforme une activité en carte, et UserProfileModal assemble le profil :
1function ActivityDisplay({ activity }: { activity: Activity }) {
2 const getActivityIcon = () => {
3 switch (activity.type) {
4 case 'playing': return '';
5 case 'streaming': return '';
6 case 'listening': return '';
7 case 'watching': return '';
8 case 'custom': return activity.customStatus?.emoji || '';
9 }
10 };
11
12 const getActivityPrefix = () => {
13 switch (activity.type) {
14 case 'playing': return 'Joue à';
15 case 'streaming': return 'Streame';
16 case 'listening': return 'Écoute';
17 case 'watching': return 'Regarde';
18 default: return '';
19 }
20 };
21
22 const getElapsedTime = () => {
23 if (!activity.timestamps?.start) return null;
24
25 const elapsed = Date.now() - activity.timestamps.start;
26 const minutes = Math.floor(elapsed / 60000);
27 const hours = Math.floor(minutes / 60);
28
29 if (hours > 0) {
30 return `${hours}:${(minutes % 60).toString().padStart(2, '0')} écoulées`;
31 }
32 return `${minutes}:${Math.floor((elapsed % 60000) / 1000).toString().padStart(2, '0')} écoulées`;
33 };
34
35 return (
36 <div className="bg-gray-700 rounded p-3 mb-2">
37 <div className="flex items-start gap-3">
38 {activity.assets?.largeImage && (
39 <img
40 src={activity.assets.largeImage}
41 alt={activity.assets.largeText || activity.name}
42 className="w-16 h-16 rounded"
43 />
44 )}
45
46 <div className="flex-1 min-w-0">
47 <div className="text-xs font-semibold text-gray-400 mb-1">
48 {getActivityIcon()} {getActivityPrefix()}
49 </div>
50
51 <div className="font-semibold text-white text-sm mb-1">
52 {activity.name}
53 </div>
54
55 {activity.details && (
56 <div className="text-xs text-gray-400">{activity.details}</div>
57 )}
58
59 {activity.state && (
60 <div className="text-xs text-gray-400">{activity.state}</div>
61 )}
62
63 {activity.party && (
64 <div className="text-xs text-gray-400">
65 {activity.party.size[0]} sur {activity.party.size[1]} dans le groupe
66 </div>
67 )}
68
69 {getElapsedTime() && (
70 <div className="text-xs text-gray-400 mt-1">
71 {getElapsedTime()}
72 </div>
73 )}
74 </div>
75 </div>
76
77 {activity.type === 'streaming' && activity.url && (
78 <button className="w-full mt-2 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-semibold text-white">
79 Regarder le stream
80 </button>
81 )}
82 </div>
83 );
84}
85
86export default function UserProfileModal({ user, presence }: {
87 user: Member;
88 presence: UserPresence;
89}) {
90 return (
91 <div className="w-80 bg-gray-800 rounded-lg overflow-hidden">
92 {/* Bannière */}
93 {user.user.banner && (
94 <div
95 className="h-24"
96 style={{
97 backgroundImage: `url(${user.user.banner})`,
98 backgroundSize: 'cover'
99 }}
100 />
101 )}
102
103 {/* Profil */}
104 <div className="p-4">
105 <div className="flex items-start gap-3 -mt-8 mb-4">
106 <div className="relative">
107 <img
108 src={user.user.avatar}
109 alt={user.user.username}
110 className="w-20 h-20 rounded-full border-4 border-gray-800"
111 />
112 <div
113 className={`absolute bottom-0 right-0 w-6 h-6 rounded-full border-4 border-gray-800 ${
114 presence.status === 'online' ? 'bg-green-500' :
115 presence.status === 'idle' ? 'bg-yellow-500' :
116 presence.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
117 }`}
118 />
119 </div>
120 </div>
121
122 <h3 className="text-xl font-bold text-white mb-1">
123 {user.nickname || user.user.username}
124 </h3>
125 <p className="text-sm text-gray-400 mb-4">
126 {user.user.username}#{user.user.discriminator}
127 </p>
128
129 {/* Statut personnalisé */}
130 {presence.customStatus && (
131 <div className="bg-gray-700 rounded p-2 mb-4 flex items-center gap-2">
132 {presence.customStatus.emoji && (
133 <span className="text-xl">{presence.customStatus.emoji}</span>
134 )}
135 <span className="text-sm text-white">{presence.customStatus.text}</span>
136 </div>
137 )}
138
139 {/* Activités */}
140 {presence.activities.length > 0 && (
141 <div className="mb-4">
142 <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">
143 Activité
144 </h4>
145 {presence.activities.map((activity, idx) => (
146 <ActivityDisplay key={idx} activity={activity} />
147 ))}
148 </div>
149 )}
150
151 {/* Membre depuis */}
152 <div className="text-xs text-gray-400">
153 Membre depuis {user.joinedAt.toLocaleDateString()}
154 </div>
155 </div>
156 </div>
157 );
158}getElapsedTime lit activity.timestamps.start, calcule elapsed = Date.now() - start, convertit le résultat en minutes et en heures et renvoie un texte au format X:YY écoulées. Le bouton Regarder le stream n'apparaît que si activity.type === 'streaming' et que activity.url existe. Les sections de la modale vont de haut en bas : bannière, avatar avec statut, statut personnalisé et activités, c'est-à-dire la présence enrichie.
Conseil du Capitaine Barbe-Rouge : enregistre le moment du début, pas le temps écoulé. Le début ne change pas, donc chaque pirate, même après un rechargement, en déduit le même temps de jeu.
À retenir : la voix, c'est un état de connexion plus un flux venant du micro, et la présence enrichie, ce sont des données d'activité que le serveur diffuse au reste de la guilde. Dans l'éditeur ci-dessous, tu as le salon vocal Battle Coordination : rejoins-le, coupe le micro, coupe le son, puis fais les exercices.
Code de cette leçon : App.jsx
1// Salon vocal avec les utilisateurs connectés
2import { useState } from 'react';
3
4const channel = {
5 name: 'Battle Coordination',
6 type: 'voice',
7 userLimit: 10,
8 connectedUsers: [
9 { id: 1, username: 'Jack Sparrow', avatar: '', isMuted: false, isDeafened: false, isSpeaking: true },
10 { id: 2, username: 'Will Turner', avatar: '', isMuted: false, isDeafened: false, isSpeaking: false },
11 { id: 3, username: 'Elizabeth Swan', avatar: '', isMuted: true, isDeafened: false, isSpeaking: false }
12 ]
13};
14
15export default function App() {
16 const [isConnected, setIsConnected] = useState(false);
17 const [isMuted, setIsMuted] = useState(false);
18 const [isDeafened, setIsDeafened] = useState(false);
19
20 return (
21 <div className="min-h-screen bg-gray-100 p-8">
22 <div className="max-w-2xl mx-auto">
23 <div className="bg-gray-700 rounded-lg p-4">
24 {/* En-tête du salon */}
25 <div className="flex items-center justify-between mb-4">
26 <div className="flex items-center gap-2">
27 <span className="text-sm font-semibold">Son</span>
28 <div>
29 <h3 className="font-bold text-white">{channel.name}</h3>
30 <p className="text-xs text-gray-400">
31 {channel.connectedUsers.length} / {channel.userLimit} connectés
32 </p>
33 </div>
34 </div>
35
36 {!isConnected ? (
37 <button
38 onClick={() => setIsConnected(true)}
39 className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold"
40 >
41 Rejoindre le vocal
42 </button>
43 ) : (
44 <button
45 onClick={() => setIsConnected(false)}
46 className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
47 >
48 Se déconnecter
49 </button>
50 )}
51 </div>
52
53 {/* Utilisateurs connectés */}
54 <div className="space-y-2">
55 {channel.connectedUsers.map(user => (
56 <div
57 key={user.id}
58 className="flex items-center gap-3 p-2 rounded bg-gray-800"
59 >
60 <div className="relative text-4xl">
61 {user.avatar}
62 {user.isSpeaking && (
63 <div className="absolute inset-0 rounded-full ring-2 ring-green-500"></div>
64 )}
65 </div>
66
67 <div className="flex-1">
68 <div className="text-white text-sm font-medium">{user.username}</div>
69 </div>
70
71 {/* Icônes de statut */}
72 <div className="flex items-center gap-1 text-gray-400">
73 {user.isMuted ? (
74 <span className="text-red-500">Muet</span>
75 ) : (
76 <span>Micro</span>
77 )}
78 {user.isDeafened ? (
79 <span className="text-red-500">Muet</span>
80 ) : (
81 <span>Casque</span>
82 )}
83 </div>
84 </div>
85 ))}
86 </div>
87
88 {/* Commandes vocales */}
89 {isConnected && (
90 <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
91 <button
92 onClick={() => setIsMuted(!isMuted)}
93 className={`p-3 rounded-full ${
94 isMuted ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
95 }`}
96 title={isMuted ? 'Réactiver le micro' : 'Couper le micro'}
97 >
98 {isMuted ? 'Muet' : 'Micro'}
99 </button>
100
101 <button
102 onClick={() => setIsDeafened(!isDeafened)}
103 className={`p-3 rounded-full ${
104 isDeafened ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
105 }`}
106 title={isDeafened ? 'Réactiver le son' : 'Couper le son'}
107 >
108 {isDeafened ? 'Muet' : 'Casque'}
109 </button>
110
111 <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Partage d'écran">
112 TV
113 </button>
114
115 <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Vidéo">
116 Vidéo
117 </button>
118 </div>
119 )}
120 </div>
121 </div>
122 </div>
123 );
124}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que contient VoiceState ?
2. En quoi isMuted diffère-t-il d'isDeafened dans VoiceState ?
Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les étapes pour rejoindre un salon vocal :
- Clique dans l'ordre
Clique pour construire la définition du champ optionnel userLimit :
- Éditeur de code
Composant de salon vocal
- Tri vertical
Ordonne les sections de la modale de profil utilisateur (UserProfileModal) de haut en bas :
- Clique dans l'ordre
Clique pour construire l'accès à l'heure de début de l'activité :
- Éditeur de code
Composant de présence enrichie
- Tri vertical
Ordonne les étapes du calcul du temps écoulé dans getElapsedTime() :
- Tri horizontal
Ordonne l'appel de la méthode qui récupère l'horodatage actuel :