Kurs vibe coding z AI · Moduł 9: Discord Clone
Voice Channels i Rich Presence
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. Co zawiera VoiceState?
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: