Kurs vibe coding z AI · Moduł 9: Discord Clone

Voice Channels i Rich Presence

8 min czytania
W tej lekcji5

Przed abordażem nikt nie ma czasu pisać na czacie: załoga musi rozmawiać na żywo, a kapitan chce widzieć, kto jest na pokładzie i czym się zajmuje. Discord rozwiązuje to kanałami głosowymi, do których wchodzi się jak do kajuty, oraz rich presence, czyli aktywnością w grze widoczną dla całej gildii.

Stan połączenia głosowego

Każda osoba w kanale głosowym ma swój stan głosowy, opisany typem VoiceState:

interface VoiceState {
  userId: string;
  channelId: string;
  serverId: string;

  isMuted: boolean; // mic muted
  isDeafened: boolean; // sound off
  isSelfMuted: boolean;
  isSelfDeafened: boolean;
  isSuppressed: boolean; // muted by server

  isStreaming: boolean; // screen share
  isVideo: boolean; // camera on

  sessionId: string;
  joinedAt: Date;
}

interface VoiceChannel extends Channel {
  type: 'voice';
  userLimit?: number; // 0 = unlimited
  bitrate: number; // kbps

  connectedUsers: VoiceState[];
}

VoiceState zawiera między innymi isMuted, isDeafened, isStreaming, isVideo i channelId. isMuted wyłącza mikrofon, więc inni Cię nie słyszą, a isDeafened wyłącza dźwięk, czyli Ty nie słyszysz innych. Pola ze słowem Self oznaczają wyciszenie przez samego użytkownika, a isSuppressed wyciszenie przez serwer. W kanale głosowym userLimit?: number to opcjonalny limit osób, a 0 oznacza brak limitu.

Jak działa dołączanie

Dołączanie ma cztery kroki: klik Join Voice na kanale, połączenie WebRTC, dodanie użytkownika do tablicy connectedUsers i pokazanie kontrolek głosu (mute, deafen). WebRTC to technologia przeglądarek do przesyłania dźwięku i obrazu w czasie rzeczywistym. Zanim cokolwiek popłynie, przeglądarka musi dostać zgodę na mikrofon:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const [microphone] = stream.getAudioTracks();

microphone.enabled = false; // wyciszenie: inni słyszą ciszę

getUserMedia pyta użytkownika o zgodę i działa tylko na stronach HTTPS albo na localhost. Wyłączona ścieżka wysyła ciszę, a połączenie zostaje otwarte. Przy większych kanałach rozmowa nie płynie bezpośrednio między przeglądarkami, tylko przez serwer pośredniczący, i tak działa też Discord.

Widok kanału głosowego

Komponent pokazuje nagłówek z liczbą osób, listę połączonych i kontrolki:

'use client';

import { useState } from 'react';

interface VoiceChannelViewProps {
  channel: VoiceChannel;
  currentUserId: string;
  onJoin: () => void;
  onLeave: () => void;
  onToggleMute: () => void;
  onToggleDeafen: () => void;
}

export default function VoiceChannelView({
  channel,
  currentUserId,
  onJoin,
  onLeave,
  onToggleMute,
  onToggleDeafen
}: VoiceChannelViewProps) {
  const currentUserVoice = channel.connectedUsers.find(v => v.userId === currentUserId);
  const isConnected = !!currentUserVoice;

  return (
    <div className="bg-gray-700 rounded-lg p-4">
      {/* Channel Header */}
      <div className="flex items-center justify-between mb-4">
        <div className="flex items-center gap-2">
          <span className="text-sm font-semibold">Dźwięk</span>
          <div>
            <h3 className="font-bold text-white">{channel.name}</h3>
            <p className="text-xs text-gray-400">
              {channel.connectedUsers.length}
              {channel.userLimit ? ` / ${channel.userLimit}` : ''} connected
            </p>
          </div>
        </div>

        {!isConnected ? (
          <button
            onClick={onJoin}
            disabled={!!channel.userLimit && channel.connectedUsers.length >= channel.userLimit}
            className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded font-semibold"
          >
            Join Voice
          </button>
        ) : (
          <button
            onClick={onLeave}
            className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
          >
            Disconnect
          </button>
        )}
      </div>

      {/* Connected Users */}
      <div className="space-y-2">
        {channel.connectedUsers.map(voice => (
          <div
            key={voice.userId}
            className={`flex items-center gap-3 p-2 rounded ${
              voice.userId === currentUserId ? 'bg-gray-600' : 'bg-gray-800'
            }`}
          >
            <div className="relative">
              <img
                src={`/users/${voice.userId}/avatar.jpg`}
                alt="User avatar"
                className={`w-10 h-10 rounded-full ${
                  voice.isSpeaking ? 'ring-2 ring-green-500' : ''
                }`}
              />
              {voice.isStreaming && (
                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center">
                  <span className="text-xs">TV</span>
                </div>
              )}
              {voice.isVideo && (
                <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
                  <span className="text-xs">Wideo</span>
                </div>
              )}
            </div>

            <div className="flex-1 min-w-0">
              <div className="text-white text-sm font-medium truncate">
                {voice.username}
                {voice.userId === currentUserId && (
                  <span className="text-gray-400"> (You)</span>
                )}
              </div>
            </div>

            {/* Status Icons */}
            <div className="flex items-center gap-1 text-gray-400">
              {voice.isMuted || voice.isSelfMuted ? (
                <span className="text-red-500">Wyciszony</span>
              ) : (
                <span>Mikrofon</span>
              )}
              {voice.isDeafened || voice.isSelfDeafened ? (
                <span className="text-red-500">Wyciszony</span>
              ) : (
                <span>Słuchawki</span>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* Voice Controls (if connected) */}
      {isConnected && (
        <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
          <button
            onClick={onToggleMute}
            className={`p-3 rounded-full ${
              currentUserVoice.isSelfMuted
                ? 'bg-red-600 hover:bg-red-700'
                : 'bg-gray-600 hover:bg-gray-500'
            }`}
            title={currentUserVoice.isSelfMuted ? 'Unmute' : 'Mute'}
          >
            {currentUserVoice.isSelfMuted ? 'Wyciszony' : 'Mikrofon'}
          </button>

          <button
            onClick={onToggleDeafen}
            className={`p-3 rounded-full ${
              currentUserVoice.isSelfDeafened
                ? 'bg-red-600 hover:bg-red-700'
                : 'bg-gray-600 hover:bg-gray-500'
            }`}
            title={currentUserVoice.isSelfDeafened ? 'Undeafen' : 'Deafen'}
          >
            {currentUserVoice.isSelfDeafened ? 'Wyciszony' : 'Słuchawki'}
          </button>

          <button
            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
            title="Screen Share"
          >
            TV
          </button>

          <button
            className="p-3 rounded-full bg-gray-600 hover:bg-gray-500"
            title="Video"
          >
            Wideo
          </button>
        </div>
      )}
    </div>
  );
}

Przycisk Join Voice jest zablokowany, gdy kanał osiągnął userLimit. Osoba, która mówi (isSpeaking), dostaje zielony ring, czyli obwódkę wokół avatara, dzięki klasom ring-2 ring-green-500. Kontrolki pojawiają się dopiero po połączeniu. Widok czyta też voice.isSpeaking i voice.username, których nie ma w typie VoiceState, więc w pełnym projekcie dopisz je do typu, inaczej TypeScript zgłosi błąd.

Rich presence

Rich presence to wyświetlanie aktywności w grze ze szczegółami (details), znacznikami czasu (timestamps) i informacją o drużynie (party):

interface Activity {
  type: 'playing' | 'streaming' | 'listening' | 'watching' | 'custom';
  name: string; // "Sea of Thieves"

  // Game specific
  details?: string; // "In a Battle"
  state?: string; // "4 players"
  timestamps?: {
    start?: number; // Unix timestamp
    end?: number;
  };

  // Assets (images)
  assets?: {
    largeImage?: string;
    largeText?: string;
    smallImage?: string;
    smallText?: string;
  };

  // Party info
  party?: {
    id: string;
    size: [number, number]; // [current, max]
  };

  // For streaming
  url?: string; // Twitch/YouTube URL
}

interface UserPresence {
  userId: string;
  status: 'online' | 'idle' | 'dnd' | 'offline';
  activities: Activity[];
  customStatus?: {
    text: string;
    emoji?: string;
    expiresAt?: Date;
  };
}

Pole type w interfejsie Activity przyjmuje wartości playing, streaming, listening, watching i custom, a API Discorda ma jeszcze competing. party.size to tablica [current, max]: aktualna i maksymalna liczba graczy. Czas startu odczytasz przez activity.timestamps.start, w milisekundach od 1970 roku. customStatus w UserPresence zawiera tekst, opcjonalne emoji oraz datę wygaśnięcia expiresAt.

Aktywność i profil gracza

ActivityDisplay zamienia aktywność na kartę, a UserProfileModal składa z kart profil:

function ActivityDisplay({ activity }: { activity: Activity }) {
  const getActivityIcon = () => {
    switch (activity.type) {
      case 'playing': return '';
      case 'streaming': return '';
      case 'listening': return '';
      case 'watching': return '';
      case 'custom': return activity.customStatus?.emoji || '';
    }
  };

  const getActivityPrefix = () => {
    switch (activity.type) {
      case 'playing': return 'Playing';
      case 'streaming': return 'Streaming';
      case 'listening': return 'Listening to';
      case 'watching': return 'Watching';
      default: return '';
    }
  };

  const getElapsedTime = () => {
    if (!activity.timestamps?.start) return null;

    const elapsed = Date.now() - activity.timestamps.start;
    const minutes = Math.floor(elapsed / 60000);
    const hours = Math.floor(minutes / 60);

    if (hours > 0) {
      return `${hours}:${(minutes % 60).toString().padStart(2, '0')} elapsed`;
    }
    return `${minutes}:${Math.floor((elapsed % 60000) / 1000).toString().padStart(2, '0')} elapsed`;
  };

  return (
    <div className="bg-gray-700 rounded p-3 mb-2">
      <div className="flex items-start gap-3">
        {activity.assets?.largeImage && (
          <img
            src={activity.assets.largeImage}
            alt={activity.assets.largeText || activity.name}
            className="w-16 h-16 rounded"
          />
        )}

        <div className="flex-1 min-w-0">
          <div className="text-xs font-semibold text-gray-400 mb-1">
            {getActivityIcon()} {getActivityPrefix()}
          </div>

          <div className="font-semibold text-white text-sm mb-1">
            {activity.name}
          </div>

          {activity.details && (
            <div className="text-xs text-gray-400">{activity.details}</div>
          )}

          {activity.state && (
            <div className="text-xs text-gray-400">{activity.state}</div>
          )}

          {activity.party && (
            <div className="text-xs text-gray-400">
              {activity.party.size[0]} of {activity.party.size[1]} in party
            </div>
          )}

          {getElapsedTime() && (
            <div className="text-xs text-gray-400 mt-1">
              {getElapsedTime()}
            </div>
          )}
        </div>
      </div>

      {activity.type === 'streaming' && activity.url && (
        <button className="w-full mt-2 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-semibold text-white">
          Watch Stream
        </button>
      )}
    </div>
  );
}

export default function UserProfileModal({ user, presence }: {
  user: Member;
  presence: UserPresence;
}) {
  return (
    <div className="w-80 bg-gray-800 rounded-lg overflow-hidden">
      {/* Banner */}
      {user.user.banner && (
        <div
          className="h-24"
          style={{
            backgroundImage: `url(${user.user.banner})`,
            backgroundSize: 'cover'
          }}
        />
      )}

      {/* Profile */}
      <div className="p-4">
        <div className="flex items-start gap-3 -mt-8 mb-4">
          <div className="relative">
            <img
              src={user.user.avatar}
              alt={user.user.username}
              className="w-20 h-20 rounded-full border-4 border-gray-800"
            />
            <div
              className={`absolute bottom-0 right-0 w-6 h-6 rounded-full border-4 border-gray-800 ${
                presence.status === 'online' ? 'bg-green-500' :
                presence.status === 'idle' ? 'bg-yellow-500' :
                presence.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
              }`}
            />
          </div>
        </div>

        <h3 className="text-xl font-bold text-white mb-1">
          {user.nickname || user.user.username}
        </h3>
        <p className="text-sm text-gray-400 mb-4">
          {user.user.username}#{user.user.discriminator}
        </p>

        {/* Custom Status */}
        {presence.customStatus && (
          <div className="bg-gray-700 rounded p-2 mb-4 flex items-center gap-2">
            {presence.customStatus.emoji && (
              <span className="text-xl">{presence.customStatus.emoji}</span>
            )}
            <span className="text-sm text-white">{presence.customStatus.text}</span>
          </div>
        )}

        {/* Activities */}
        {presence.activities.length > 0 && (
          <div className="mb-4">
            <h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">
              Activity
            </h4>
            {presence.activities.map((activity, idx) => (
              <ActivityDisplay key={idx} activity={activity} />
            ))}
          </div>
        )}

        {/* Member Since */}
        <div className="text-xs text-gray-400">
          Member since {user.joinedAt.toLocaleDateString()}
        </div>
      </div>
    </div>
  );
}

getElapsedTime pobiera activity.timestamps.start, liczy elapsed = Date.now() - start, przelicza wynik na minuty i godziny i zwraca tekst w formacie X:YY elapsed. Przycisk Watch Stream pojawia się tylko wtedy, gdy activity.type === 'streaming' i istnieje activity.url. Sekcje modala idą od góry: baner, avatar ze statusem, własny status (custom status) i aktywności, czyli rich presence.

Rada Kapitana Redbearda: zapisuj moment startu, a nie to, ile minęło. Start się nie zmienia, więc każdy pirat, także po odświeżeniu strony, wyliczy z niego ten sam czas gry.

Zapamiętaj: głos to stan połączenia plus strumień z mikrofonu, a rich presence to dane o aktywności, które serwer rozsyła reszcie gildii. W edytorze poniżej masz kanał głosowy Battle Coordination: dołącz, wycisz mikrofon i wyłącz dźwięk, a potem rozwiąż zadania.

Kod do tej lekcji: App.jsx
// Kanał głosowy z połączonymi użytkownikami
import { useState } from 'react';

const channel = {
  name: 'Koordynacja bitwy',
  type: 'voice',
  userLimit: 10,
  connectedUsers: [
    { id: 1, username: 'Jack Sparrow', avatar: '', isMuted: false, isDeafened: false, isSpeaking: true },
    { id: 2, username: 'Will Turner', avatar: '', isMuted: false, isDeafened: false, isSpeaking: false },
    { id: 3, username: 'Elizabeth Swan', avatar: '', isMuted: true, isDeafened: false, isSpeaking: false }
  ]
};

export default function App() {
  const [isConnected, setIsConnected] = useState(false);
  const [isMuted, setIsMuted] = useState(false);
  const [isDeafened, setIsDeafened] = useState(false);

  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-2xl mx-auto">
        <div className="bg-gray-700 rounded-lg p-4">
          {/* Nagłówek kanału */}
          <div className="flex items-center justify-between mb-4">
            <div className="flex items-center gap-2">
              <span className="text-sm font-semibold">Dźwięk</span>
              <div>
                <h3 className="font-bold text-white">{channel.name}</h3>
                <p className="text-xs text-gray-400">
                  Połączeni: {channel.connectedUsers.length} / {channel.userLimit}
                </p>
              </div>
            </div>

            {!isConnected ? (
              <button
                onClick={() => setIsConnected(true)}
                className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold"
              >
                Dołącz do kanału
              </button>
            ) : (
              <button
                onClick={() => setIsConnected(false)}
                className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
              >
                Rozłącz
              </button>
            )}
          </div>

          {/* Połączeni użytkownicy */}
          <div className="space-y-2">
            {channel.connectedUsers.map(user => (
              <div
                key={user.id}
                className="flex items-center gap-3 p-2 rounded bg-gray-800"
              >
                <div className="relative text-4xl">
                  {user.avatar}
                  {user.isSpeaking && (
                    <div className="absolute inset-0 rounded-full ring-2 ring-green-500"></div>
                  )}
                </div>

                <div className="flex-1">
                  <div className="text-white text-sm font-medium">{user.username}</div>
                </div>

                {/* Ikony statusu */}
                <div className="flex items-center gap-1 text-gray-400">
                  {user.isMuted ? (
                    <span className="text-red-500">Wyciszony</span>
                  ) : (
                    <span>Mikrofon</span>
                  )}
                  {user.isDeafened ? (
                    <span className="text-red-500">Wyciszony</span>
                  ) : (
                    <span>Słuchawki</span>
                  )}
                </div>
              </div>
            ))}
          </div>

          {/* Sterowanie głosem */}
          {isConnected && (
            <div className="mt-4 pt-4 border-t border-gray-600 flex items-center justify-center gap-3">
              <button
                onClick={() => setIsMuted(!isMuted)}
                className={`p-3 rounded-full ${
                  isMuted ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
                }`}
                title={isMuted ? 'Włącz mikrofon' : 'Wycisz mikrofon'}
              >
                {isMuted ? 'Wyciszony' : 'Mikrofon'}
              </button>

              <button
                onClick={() => setIsDeafened(!isDeafened)}
                className={`p-3 rounded-full ${
                  isDeafened ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-600 hover:bg-gray-500'
                }`}
                title={isDeafened ? 'Włącz dźwięk' : 'Wyłącz dźwięk'}
              >
                {isDeafened ? 'Wyciszony' : 'Słuchawki'}
              </button>

              <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Udostępnij ekran">
                TV
              </button>

              <button className="p-3 rounded-full bg-gray-600 hover:bg-gray-500" title="Wideo">
                Wideo
              </button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co zawiera VoiceState?

  2. 2. Czym różni się isMuted od isDeafened w VoiceState?

To 2 z 8 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż kroki dołączania do voice channel:

  • Klikanie w kolejności

    Klikaj, aby zbudować definicję opcjonalnego pola userLimit:

  • Edytor kodu

    Voice channel component

  • Układanie w pionie

    Ułóż sekcje modala profilu użytkownika (UserProfileModal) z góry na dół:

  • Klikanie w kolejności

    Klikaj, aby zbudować dostęp do czasu startu aktywności:

  • Edytor kodu

    Rich presence component

  • Układanie w pionie

    Ułóż kroki obliczania elapsed time w getElapsedTime():

  • Układanie w poziomie

    Ułóż wywołanie metody pobierającej aktualny timestamp:

Przydatne artykuły