video-sdk/web

Par anthropics · knowledge-work-plugins

Zoom Video SDK pour le Web - Intégration JavaScript/TypeScript pour les sessions vidéo dans le navigateur, la communication en temps réel, le partage d'écran, l'enregistrement et la transcription en direct

npx skills add https://github.com/anthropics/knowledge-work-plugins --skill video-sdk/web

Zoom Video SDK - Développement Web

Guide expert pour développer avec le Zoom Video SDK sur le Web. Ce SDK permet de créer des applications vidéo personnalisées dans le navigateur avec vidéo/audio en temps réel, partage d'écran, enregistrement cloud, streaming en direct, chat et transcription en direct.

Cette skill concerne les sessions vidéo personnalisées, pas les réunions Zoom intégrées. Si l'utilisateur souhaite une UI personnalisée pour une vraie réunion Zoom, orientez-le vers ../../meeting-sdk/web/component-view/SKILL.md.

Documentation officielle : https://developers.zoom.us/docs/video-sdk/web/ Référence API : https://marketplacefront.zoom.us/sdk/custom/web/modules.html Référentiel exemple : https://github.com/zoom/videosdk-web-sample

Liens rapides

Nouveau avec Video SDK ? Suivez ce chemin :

  1. Modèle d'architecture SDK - Modèle 3 étapes universel pour TOUTE fonction
  2. Modèle de connexion de session - Code complet et fonctionnel pour rejoindre une session
  3. Rendu vidéo - Afficher la vidéo avec attachVideo()
  4. Gestion des événements - Événements requis pour la vidéo/audio

Référence :

Vous avez des problèmes ?

  • La vidéo ne s'affiche pas → Rendu vidéo (utilisez attachVideo, pas renderVideo)
  • getMediaStream() retourne undefined → Appelez APRÈS que join() soit terminé
  • Diagnostics rapides → Problèmes courants

Aperçu du SDK

Le Zoom Video SDK pour le Web est une bibliothèque JavaScript qui fournit :

  • Gestion de session : Rejoindre/quitter les sessions Video SDK
  • Vidéo/Audio : Démarrer/arrêter la caméra et le microphone
  • Partage d'écran : Partager des écrans ou des onglets de navigateur
  • Enregistrement Cloud : Enregistrer les sessions sur le cloud Zoom
  • Streaming en direct : Diffuser vers des endpoints RTMP
  • Chat : Messagerie en session
  • Canal de commande : Messagerie de commande personnalisée
  • Transcription en direct : Conversion vocale en texte en temps réel
  • Sous-sessions : Support de salles parallèles
  • Tableau blanc : Fonctionnalités de tableau blanc collaboratif
  • Arrière-plan virtuel : Flouter ou appliquer des arrière-plans d'images personnalisés

Prérequis

Configuration requise du système

  • Navigateur moderne : Chrome 80+, Firefox 75+, Safari 14+, Edge 80+
  • Identifiants Video SDK : Clé et secret SDK de Marketplace
  • Token JWT : Signature générée côté serveur

Exigences de fonctionnalités du navigateur

// Vérifiez la compatibilité du navigateur avant init
const compatibility = ZoomVideo.checkSystemRequirements();
console.log('Audio:', compatibility.audio);
console.log('Vidéo:', compatibility.video);
console.log('Écran:', compatibility.screen);

// Vérifiez la prise en charge des fonctionnalités
const features = ZoomVideo.checkFeatureRequirements();
console.log('Supporté:', features.supportFeatures);
console.log('Non supporté:', features.unSupportFeatures);

Diagnostics optionnels avant connexion (recommandé pour la fiabilité)

Utilisez Probe SDK comme passerelle de disponibilité avant client.join(...) quand vous avez besoin de réduire les démarrages échoués :

  1. Exécutez les diagnostics avec ../../probe-sdk/SKILL.md.
  2. Évaluez la politique (allow, warn, block).
  3. Démarrez la connexion Video SDK seulement quand la politique l'autorise.

Flux entre skills : ../../general/use-cases/probe-sdk-preflight-readiness-gate.md

Installation

NPM (recommandé)

npm install @zoom/videosdk
import ZoomVideo from '@zoom/videosdk';

CDN (stratégie de fallback recommandée)

Note : Certains réseaux/bloqueurs de publicités peuvent bloquer source.zoom.us. Si vous voyez des chargements instables, essayez d'abord d'allowlister le domaine dans votre environnement. Si nécessaire, envisagez un fallback (miroir/auto-hébergement) seulement s'il est autorisé pour votre cas d'usage et que vous pouvez garder les versions synchronisées.

# Téléchargez le SDK localement
curl "https://source.zoom.us/videosdk/zoom-video-2.3.12.min.js" -o public/js/zoom-video-sdk.min.js
<!-- Utilisez la copie locale au lieu du CDN -->
<script src="js/zoom-video-sdk.min.js"></script>
// Le CDN exporte comme WebVideoSDK, PAS ZoomVideo
const ZoomVideo = WebVideoSDK.default;

Démarrage rapide

import ZoomVideo from '@zoom/videosdk';

// 1. Créer le client (singleton - retourne la même instance)
const client = ZoomVideo.createClient();

// 2. Initialiser le SDK
await client.init('en-US', 'Global', { patchJsMedia: true });

// 3. Rejoindre la session
await client.join(topic, signature, userName, password);

// 4. CRITIQUE : Obtenir le stream APRÈS join
const stream = client.getMediaStream();

// 5. Démarrer les médias
await stream.startVideo();
await stream.startAudio();

// 6. Attacher la vidéo au DOM
const videoElement = await stream.attachVideo(userId, VideoQuality.Video_360P);
document.getElementById('video-container').appendChild(videoElement);

Cycle de vie du SDK (ORDRE CRITIQUE)

Le SDK a un cycle de vie strict. Le violer provoque des défaillances silencieuses.

1. Créer le client:     client = ZoomVideo.createClient()
2. Initialiser:         await client.init('en-US', 'Global', options)
3. Rejoindre la session: await client.join(topic, signature, userName, password)
4. Obtenir le stream:    stream = client.getMediaStream()  ← SEULEMENT APRÈS JOIN
5. Démarrer les médias:  await stream.startVideo() / await stream.startAudio()

Erreur courante :

// MAUVAIS : Obtenir le stream avant de rejoindre
const stream = client.getMediaStream();  // Retourne undefined !
await client.join(...);

// CORRECT : Obtenir le stream après avoir rejoint
await client.join(...);
const stream = client.getMediaStream();  // Fonctionne !

Pièges critiques et meilleures pratiques

getMediaStream() FONCTIONNE UNIQUEMENT après join()

Le problème #1 qui provoque l'échec de la vidéo/audio :

// MAUVAIS
const stream = client.getMediaStream();  // undefined !
await client.join(...);

// CORRECT
await client.join(...);
const stream = client.getMediaStream();  // Fonctionne

Utilisez attachVideo() PAS renderVideo()

renderVideo() est déprécié. Utilisez attachVideo() qui retourne un élément VideoPlayer :

import { VideoQuality } from '@zoom/videosdk';

// CORRECT : attachVideo retourne un élément à ajouter
const videoElement = await stream.attachVideo(userId, VideoQuality.Video_360P);
document.getElementById('video-container').appendChild(videoElement);

// MAUVAIS : renderVideo est déprécié
await stream.renderVideo(canvas, userId, ...);  // Ne pas utiliser !

Le rendu vidéo est piloté par événements (CRITIQUE)

Vous DEVEZ écouter les événements pour bien afficher les vidéos des participants :

// Quand l'état vidéo d'un autre participant change
client.on('peer-video-state-change', async (payload) => {
  const { action, userId } = payload;

  if (action === 'Start') {
    // Le participant a activé la vidéo - attachez-la
    const element = await stream.attachVideo(userId, VideoQuality.Video_360P);
    container.appendChild(element);
  } else if (action === 'Stop') {
    // Le participant a désactivé la vidéo - détachez-la
    await stream.detachVideo(userId);
  }
});

// Quand des participants rejoignent/quittent
client.on('user-added', (payload) => {
  // Un nouveau participant a rejoint - vérifiez si sa vidéo est activée
  const users = client.getAllUser();
  // Affichez les vidéos pour les utilisateurs avec bVideoOn === true
});

client.on('user-removed', (payload) => {
  // Le participant a quitté - nettoyez son élément vidéo
  stream.detachVideo(payload[0].userId);
});

Vidéo des pairs lors d'une connexion en milieu de session

Les vidéos des participants existants ne s'afficheront pas automatiquement quand vous rejoignez en milieu de session.

// Après avoir rejoint, affichez les vidéos des participants existants
const renderExistingVideos = async () => {
  await new Promise(resolve => setTimeout(resolve, 500));

  const users = client.getAllUser();
  const currentUserId = client.getCurrentUserInfo().userId;

  for (const user of users) {
    if (user.bVideoOn && user.userId !== currentUserId) {
      const element = await stream.attachVideo(user.userId, VideoQuality.Video_360P);
      document.getElementById(`video-${user.userId}`).appendChild(element);
    }
  }
};

Condition de concurrence CDN avec modules ES

Lors de l'utilisation de <script type="module"> avec CDN, le SDK peut ne pas être encore chargé :

function waitForSDK(timeout = 10000) {
  return new Promise((resolve, reject) => {
    if (typeof WebVideoSDK !== 'undefined') {
      resolve();
      return;
    }
    const start = Date.now();
    const check = setInterval(() => {
      if (typeof WebVideoSDK !== 'undefined') {
        clearInterval(check);
        resolve();
      } else if (Date.now() - start > timeout) {
        clearInterval(check);
        reject(new Error('SDK failed to load'));
      }
    }, 100);
  });
}

await waitForSDK();
const ZoomVideo = WebVideoSDK.default;

SharedArrayBuffer pour vidéo HD

Pour des performances optimales et une vidéo HD, configurez ces en-têtes sur votre serveur :

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Note : À partir de v1.11.2, SharedArrayBuffer est optionnel (pas strictement requis).

Vérifiez la capacité HD avant d'activer

const stream = client.getMediaStream();

// Vérifiez si 720p est pris en charge
const hdSupported = stream.isSupportHDVideo();

// Obtenez la qualité vidéo maximale
const maxQuality = stream.getVideoMaxQuality();
// 0=90P, 1=180P, 2=360P, 3=720P, 4=1080P

// Démarrez la vidéo en HD
if (hdSupported) {
  await stream.startVideo({ hd: true });
}

Vérification du mode de rendu de partage d'écran

const stream = client.getMediaStream();

// Vérifiez quel type d'élément utiliser
if (stream.isStartShareScreenWithVideoElement()) {
  // Utilisez un élément vidéo
  const video = document.getElementById('share-video');
  await stream.startShareScreen(video as unknown as HTMLCanvasElement);
} else {
  // Utilisez un élément canvas
  const canvas = document.getElementById('share-canvas');
  await stream.startShareScreen(canvas);
}

Fonctionnalités clés

Énumération de qualité vidéo

import { VideoQuality } from '@zoom/videosdk';

VideoQuality.Video_90P   // 0
VideoQuality.Video_180P  // 1
VideoQuality.Video_360P  // 2 (recommandé pour la plupart des cas)
VideoQuality.Video_720P  // 3
VideoQuality.Video_1080P // 4

Arrière-plans virtuels

const stream = client.getMediaStream();

// Toujours vérifier le support en premier
if (stream.isSupportVirtualBackground()) {
  // Flouter l'arrière-plan
  await stream.updateVirtualBackgroundImage('blur');

  // Arrière-plan d'image personnalisée
  await stream.updateVirtualBackgroundImage('https://example.com/bg.jpg');

  // Supprimer l'arrière-plan virtuel
  await stream.updateVirtualBackgroundImage(undefined);
}

Processeur vidéo (effets personnalisés)

La classe VideoProcessor vous permet d'intercepter et de modifier les frames vidéo :

// video-processor-worker.js
class MyVideoProcessor extends VideoProcessor {
  processFrame(input, output) {
    const ctx = output.getContext('2d');
    ctx.drawImage(input, 0, 0);

    // Ajouter une superposition
    ctx.fillStyle = 'white';
    ctx.font = '24px Arial';
    ctx.fillText('Live', 20, 40);

    return true;
  }
}

Mode WebRTC

Activez le mode WebRTC pour le streaming pair-à-pair direct avec prise en charge vidéo HD :

await client.init('en-US', 'Global', {
  patchJsMedia: true,
  webrtc: true  // Activer le mode WebRTC
});

Clients de fonctionnalités

Accédez aux clients spécialisés à partir de VideoClient :

Client Méthode d'accès Objectif
Stream client.getMediaStream() Vidéo, audio, partage d'écran, appareils
Chat client.getChatClient() Envoyer/recevoir des messages
Commande client.getCommandClient() Commandes personnalisées (réactions, etc.)
Enregistrement client.getRecordingClient() Contrôle d'enregistrement cloud
Transcription client.getLiveTranscriptionClient() Sous-titres en direct
LiveStream client.getLiveStreamClient() Streaming RTMP
Sous-session client.getSubsessionClient() Salles parallèles
Tableau blanc client.getWhiteboardClient() Tableau blanc collaboratif

Tâches courantes

Démarrer/Arrêter la vidéo

await stream.startVideo();
await stream.stopVideo();

Démarrer/Arrêter l'audio

await stream.startAudio();
await stream.muteAudio();
await stream.unmuteAudio();
await stream.stopAudio();

Changer d'appareil

// Obtenez les appareils disponibles
const cameras = stream.getCameraList();
const mics = stream.getMicList();
const speakers = stream.getSpeakerList();

// Changez d'appareils
await stream.switchCamera(cameraId);
await stream.switchMicrophone(micId);
await stream.switchSpeaker(speakerId);

Partage d'écran

// Démarrez le partage
await stream.startShareScreen(canvas);

// Arrêtez le partage
await stream.stopShareScreen();

// Recevez le partage
client.on('active-share-change', async (payload) => {
  if (payload.state === 'Active') {
    await stream.startShareView(canvas, payload.userId);
  } else {
    await stream.stopShareView();
  }
});

Chat

const chatClient = client.getChatClient();

// Envoyez à tout le monde
await chatClient.send('Bonjour à tous !');

// Envoyez à un utilisateur spécifique
await chatClient.sendToUser(userId, 'Message privé');

// Recevez les messages
client.on('chat-on-message', (payload) => {
  console.log(`${payload.sender.name}: ${payload.message}`);
});

Enregistrement (hôte uniquement)

const recordingClient = client.getRecordingClient();

await recordingClient.startCloudRecording();
await recordingClient.stopCloudRecording();

client.on('recording-change', (payload) => {
  console.log('Statut d\'enregistrement:', payload.state);
});

Quitter/Terminer la session

// Quittez la session (les autres restent)
await client.leave();

// Terminez la session pour TOUS les participants (hôte uniquement)
await client.leave(true);

Gestion des erreurs

Erreurs de connexion courantes

Erreur Cause Solution
Invalid signature JWT expiré ou malformé Générez une nouvelle signature
Session does not exist L'hôte n'a pas encore démarré Afficher le message « en attente », réessayer
Permission denied L'utilisateur a refusé caméra/micro Demander la permission à nouveau

Exemple de gestionnaire d'erreur

try {
  await client.join(topic, signature, userName, password);
} catch (error) {
  if (error.reason?.includes('signature')) {
    // Régénérez la signature et réessayez
  } else if (error.reason?.includes('Session')) {
    // Afficher « En attente de l'hôte... » et interroger
  } else if (error.reason?.includes('Permission')) {
    // Guider l'utilisateur pour activer les permissions
  }
  console.error('Join failed:', error);
}

Compatibilité du navigateur

Fonctionnalité Chrome Firefox Safari Edge
Vidéo 80+ 75+ 14+ 80+
Audio 80+ 75+ 14+ 80+
Partage d'écran 80+ 75+ 15+ 80+
Arrière-plan virtuel 80+ 90+ - 80+

Notes Safari :

  • Arrière-plan virtuel non supporté
  • Le partage d'écran nécessite macOS 15+

Erreurs CORS (Télémétrie)

Les erreurs CORS vers log-external-gateway.zoom.us sont inoffensives.

Elles sont causées par les en-têtes COOP/COEP qui bloquent les demandes de télémétrie. Elles n'affectent pas la fonctionnalité du SDK.

Bibliothèque de documentation complète

Concepts de base

Exemples complets

Intégrations de framework

Dépannage

Références

Référentiels d'exemples officiels

Type Référentiel
Web Sample videosdk-web-sample
React SDK videosdk-react
Next.js videosdk-nextjs-quickstart
Vue/Nuxt videosdk-vue-nuxt-quickstart
Endpoint Auth videosdk-auth-endpoint-sample
UI Toolkit videosdk-zoom-ui-toolkit-react-sample

Ressources


Besoin d'aide ? Commencez par SKILL.md pour la navigation complète.

Fusionné depuis video-sdk/web/SKILL.md

Zoom Video SDK Web - Index complet de la documentation

Chemin de démarrage rapide

Si vous découvrez le SDK, suivez cet ordre :

  1. Lisez le modèle d'architectureconcepts/sdk-architecture-pattern.md

    • Formule universelle : Créer Client → Initialiser → Rejoindre → Obtenir Stream → Utiliser
    • Une fois que vous comprenez cela, vous pouvez implémenter n'importe quelle fonction
  2. Implémentez la connexion de sessionexamples/session-join-pattern.md

    • Code complet et fonctionnel JWT + connexion de session
  3. Écoutez les événementsexamples/event-handling.md

    • CRITIQUE : Le SDK est piloté par événements, vous devez écouter les événements
  4. Implémentez la vidéoexamples/video-rendering.md

    • Utilisez attachVideo(), PAS renderVideo()
  5. Dépannez les problèmestroubleshooting/common-issues.md

    • Liste de contrôle de diagnostic rapide
    • Tableaux de codes d'erreur

Structure de la documentation

video-sdk/web/
├── SKILL.md                           # Aperçu de la skill principale
├── SKILL.md                           # Ce fichier - guide de navigation
│
├── concepts/                          # Modèles d'architecture de base
│   ├── sdk-architecture-pattern.md   # Formule universelle pour TOUTE fonction
│   └── singleton-hierarchy.md        # Guide de navigation à 4 niveaux
│
├── examples/                          # Code complet et fonctionnel
│   ├── session-join-pattern.md       # Authentification JWT + connexion de session
│   ├── video-rendering.md            # Modèles attachVideo()
│   ├── screen-share.md               # Envoyer et recevoir les partages d'écran
│   ├── event-handling.md             # Événements requis
│   ├── chat.md                       # Implémentation du chat
│   ├── command-channel.md            # Messagerie par canal de commande
│   ├── recording.md                  # Contrôle d'enregistrement cloud
│   ├── transcription.md              # Transcription en direct/sous-titres
│   ├── react-hooks.md                # Bibliothèque officielle @zoom/videosdk-react
│   └── framework-integrations.md     # Modèles Next.js, Vue/Nuxt, ZFG
│
├── troubleshooting/                   # Guides de résolution de problèmes
│   └── common-issues.md              # Flux de diagnostic rapide
│
└── references/                        # Documentation de référence
    ├── web-reference.md              # Hiérarchie API, méthodes, codes d'erreur
    └── events-reference.md           # Tous les types d'événements

Par cas d'usage

Je veux créer une application vidéo

  1. Modèle d'architecture SDK - Comprenez le modèle
  2. Modèle de connexion de session - Rejoignez les sessions
  3. Rendu vidéo - Affichez la vidéo
  4. Gestion des événements - Écoutez les événements vidéo

J'obtiens des erreurs d'exécution

  1. Problèmes courants - Tableaux de codes d'erreur
  2. « getMediaStream() is undefined » → Appelez APRÈS que join() soit terminé

Je veux recevoir des partages d'écran

  1. Partage d'écran - Modèles startShareView()
  2. Gestion des événements - Événement active-share-change

Je veux envoyer des partages d'écran

  1. Partage d'écran - Modèles startShareScreen()
  2. Vérifiez isStartShareScreenWithVideoElement() pour le type d'élément

Je veux utiliser le chat

  1. Chat - Envoyez/recevez des messages
  2. getChatClient() pour l'accès à ChatClient

Je veux enregistrer les sessions

  1. Enregistrement - Enregistrement cloud (hôte uniquement)
  2. getRecordingClient() pour l'accès à RecordingClient

Je veux utiliser la transcription en direct

  1. Transcription - Activez les sous-titres en direct
  2. getLiveTranscriptionClient() pour l'accès à LiveTranscriptionClient

Je veux utiliser le canal de commande

  1. Canal de commande - Signalisation personnalisée entre participants
  2. Doit appeler getCommandClient() APRÈS join()

Je veux implémenter une fonction spécifique

  1. Modèle d'architecture SDK - COMMENCEZ ICI !
  2. Hiérarchie Singleton - Naviguez vers la fonction
  3. Référence API - Signatures de méthodes

J'utilise React

  1. React Hooks - Bibliothèque officielle @zoom/videosdk-react
  2. Fournit des hooks : useSession, useSessionUsers, useVideoState, useAudioState
  3. Composants pré-construits : VideoPlayerComponent, ScreenSharePlayerComponent

J'utilise Next.js ou Vue/Nuxt

  1. Intégrations de framework - Considérations SSR
  2. Modèles de génération JWT côté serveur
  3. Utilisation du SDK côté client uniquement

Documents les plus critiques

1. Modèle d'architecture SDK (DOCUMENT MAÎTRE)

concepts/sdk-architecture-pattern.md

Le modèle universel 5 étapes :

  1. Créer le client
  2. Initialiser le SDK
  3. Rejoindre la session
  4. Obtenir le stream
  5. Utiliser les fonctionnalités + écouter les événements

2. Problèmes courants (PROBLÈMES LES PLUS COURANTS)

troubleshooting/common-issues.md

Problèmes courants :

  • getMediaStream() retourne undefined
  • Vidéo ne s'affiche pas
  • renderVideo() déprécié

3. Hiérarchie Singleton (CARTE DE NAVIGATION)

concepts/singleton-hierarchy.md

Navigation en profondeur à 4 niveaux montrant comment accéder à chaque fonction.


Apprentissages clés

Découvertes critiques :

  1. getMediaStream() FONCTIONNE UNIQUEMENT après join()

  2. Utilisez attachVideo() PAS renderVideo()

    • renderVideo() est déprécié
    • attachVideo() retourne un élément VideoPlayer à ajouter au DOM
    • Voir : Rendu vidéo
  3. Le SDK est piloté par événements

    • Vous DEVEZ écouter les événements pour afficher les vidéos des participants
    • Événements clés : peer-video-state-change, user-added, user-removed
    • Voir : Gestion des événements
  4. Vidéos des pairs lors d'une connexion en milieu de session

    • Les vidéos des participants existants ne s'afficheront pas automatiquement
    • Doit itérer manuellement getAllUser() et attachVideo()
    • Voir : Rendu vidéo
  5. CDN vs NPM

    • Le CDN exporte comme WebVideoSDK.default, pas ZoomVideo
    • Certains réseaux/bloqueurs de publicités peuvent bloquer source.zoom.us - allowlistez ou utilisez une stratégie de fallback autorisée
    • Voir : Modèle de connexion de session
  6. SharedArrayBuffer pour HD

    • Requis pour la vidéo 720p/1080p
    • Besoin d'en-têtes COOP/COEP sur le serveur
    • Vérifiez avec stream.isSupportHDVideo()
  7. Type d'élément de partage d'écran

    • Vérifiez isStartShareScreenWithVideoElement() pour le type d'élément correct
    • Voir : Partage d'écran
  8. Ordre de configuration du canal de commande

    • Doit appeler getCommandClient() APRÈS client.join()
    • Enregistrez les écouteurs APRÈS join, pas avant
    • Le Web utilise getCommandClient() pas getCmdChannel()
    • Voir : Canal de commande
  9. Le canal de commande est délimité par la session

    • Ne s'étend PAS sur différentes sessions
    • L'expéditeur et le destinataire doivent être dans la même session

Référence rapide

« getMediaStream() retourne undefined »

→ Appelez APRÈS que join() soit terminé

« La vidéo ne s'affiche pas »

Rendu vidéo - Utilisez attachVideo(), vérifiez les événements

« renderVideo() ne fonctionne pas »

Rendu vidéo - Utilisez attachVideo() à la place

« Comment implémenter [fonction] ? »

Modèle d'architecture SDK

« Comment naviguer vers [client] ? »

Hiérarchie Singleton

« Que signifie ce code d'erreur ? »

Problèmes courants


Version du document

Basée sur Zoom Video SDK pour le Web v2.3.x


Bon codage !

Rappelez-vous : Le Modèle d'architecture SDK est votre clé pour débloquer tout le SDK. Lisez-le en premier !

Opérations

  • RUNBOOK.md - Liste de contrôle de préflight et de débogage de 5 minutes.

Skills similaires