Cours Vibe coding avec l'IA · Module 9: Clone de Discord

Salons vocaux et Rich Presence

9 min de lecture
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 silence

getUserMedia 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. 1. Que contient VoiceState ?

  2. 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 :

Articles utiles