WebSocket en temps réel

Connectez-vous à wss://cliqer.io/ws pour piloter une salle Cliqer depuis votre propre logiciel ou votre système de show control, comme avec une télécommande de présentation : changez de diapositive, pilotez le minuteur de l'hôte, pointez avec un point laser et envoyez des messages à l'hôte. Votre application rejoint la salle en tant que présentateur ; la salle elle-même est toujours hébergée par l'application de bureau Cliqer.
session_token (les navigateurs l'envoient automatiquement), un paramètre de requête ?token= ou un en-tête Authorization: Bearer. Le jeton est vérifié à la connexion, et non à chaque commande : lorsqu'il expire, ouvrez une nouvelle connexion avec un nouveau jeton et rejoignez à nouveau la salle. Sans jeton, vous pouvez vous connecter mais pas utiliser l'API. Les clés API d'équipe (cliqer_...) authentifient uniquement les points de terminaison REST ; sur ce socket, une clé n'est pas un jeton de session.Connexion
# WebSocket connections require a WebSocket client
# Using websocat for testing:
websocat "$WS_URL/ws?token=YOUR_ACCESS_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
ws.onopen = () => {
console.log('Connected to Cliqer WebSocket')
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('Received:', message)
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
import websocket
import json
ws = websocket.WebSocketApp(
f"$WS_URL/ws?token={access_token}",
on_message=lambda ws, msg: print(json.loads(msg)),
on_error=lambda ws, err: print(f"Error: {err}"),
on_open=lambda ws: print("Connected")
)
ws.run_forever()
import (
"github.com/gorilla/websocket"
)
url := "$WS_URL/ws?token=" + accessToken
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
fmt.Printf("Received: %s\n", message)
}
use WebSocket\Client;
$client = new Client("$WS_URL/ws?token={$accessToken}");
while (true) {
$message = $client->receive();
$data = json_decode($message, true);
print_r($data);
}
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{StreamExt, SinkExt};
let url = format!("$WS_URL/ws?token={}", access_token);
let (mut ws_stream, _) = connect_async(&url).await?;
while let Some(msg) = ws_stream.next().await {
match msg? {
Message::Text(text) => {
let data: serde_json::Value = serde_json::from_str(&text)?;
println!("Received: {:?}", data);
}
_ => {}
}
}
Le serveur envoie connected avec votre clientId dès qu'il a établi la connexion. Attendez cet événement avant d'envoyer quoi que ce soit : un message qui arrive plus tôt est rejeté avec une error et la connexion est fermée.
Connexion et déroulement dans la salle
Navigation dans les diapositives
Pilotez les diapositives d'une salle depuis votre propre logiciel, comme avec une télécommande de présentation. Votre application rejoint la salle en tant que présentateur, si bien que l'hôte la voit dans la liste des présentateurs sous son presenterName et la gère comme tout autre présentateur : l'opérateur peut désactiver ses clics, la retirer ou la bloquer.
- Connectez-vous avec un jeton de session (
?token=, un en-têteAuthorization: Bearerou le cookiesession_token). Les clés API d'équipe fonctionnent uniquement sur les points de terminaison REST, pas sur ce socket. - Le forfait du propriétaire de la salle doit inclure l'API client : le module complémentaire API sur Presenter, Studio HD et Studio 4K, inclus dans Studio 8K et Enterprise.
- Les commandes de diapositives et de minuteur ne s'exécutent que tant que l'hôte autorise les clics de votre connexion.
Rejoindre la salle
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- Envoyez ce message après l'événement
connected: le serveur rejette tout ce qui arrive avant. presenterNameest le nom que voit l'hôte (par défautAPI). Votre application est toujours un présentateur dans la salle, jamais son hôte.- Attendez un événement
clicksEnabledavec la charge utiletrueavant d'envoyer des commandes. L'hôte l'envoie dès qu'il a configuré votre connexion, puis chaque fois que l'opérateur active ou désactive vos clics. - Conservez la
presenterKeyreçue dansjoinedRoomet renvoyez-la dansjoinRoomlorsque vous vous reconnectez, afin que l'hôte conserve votre autorisation de clic. - Envoyez la trame texte
pingtoutes les 15 secondes pour maintenir la connexion ouverte. Le serveur répond par la trame textepong(pas du JSON).
Envoyer des commandes
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | Champ supplémentaire | Effet |
|---|---|---|
next | Diapositive (ou animation) suivante | |
prev | Diapositive (ou animation) précédente | |
first | Première diapositive | |
last | Dernière diapositive | |
goto | slide : nombre entier de 1 à 9999 | Aller à cette diapositive |
blackout | on : true ou false | Passer l'écran au noir, ou le réafficher |
idest facultatif : de 1 à 64 lettres, chiffres,_,.,:ou-. Il est renvoyé dans le résultat. Utilisez un nouvelidpour chaque commande : l'hôte ignore une commande dont il a déjà reçu l'idde votre connexion au cours des 5 dernières secondes, de sorte qu'une nouvelle tentative de la même commande ne s'exécute qu'une seule fois.first,last,gotoetblackoutne s'exécutent que sur un hôte dont le dernier événementcontrolsles liste dansnav. Un hôte qui n'envoie aucun événementcontrolsprend uniquement en chargenextetprev. Lorsque la salle compte plusieurs hôtes, chacun exécute les commandes qu'il prend en charge.nextouprevpendant que l'écran est au noir le réaffiche sans changer de diapositive.- Un
gotoau-delà de la dernière diapositive est ignoré. - Envoyez au maximum 10 commandes par seconde, commandes de diapositives et de minuteur confondues. L'hôte ignore également toute répétition d'une même commande dans un délai de 50 millisecondes.
Ce que chaque application de présentation prend en charge aujourd'hui :
| Application de présentation | next, prev | first, last | goto | blackout |
|---|---|---|---|---|
| PowerPoint sur Windows | Oui | Oui | Oui | Oui |
| PowerPoint sur Mac | Oui | Oui | Non | Non |
| Keynote | Oui | Non | Non | Oui |
| Google Slides (extension de navigateur connectée) | Oui | Oui | Non | Non |
| vMix | Oui | Non | Non | Non |
| Fenêtre active | Oui | Non | Non | Non |
L'événement controls donne la réponse en direct pour l'hôte que vous pilotez : lisez nav dans cet événement plutôt que dans ce tableau.
# One connection: wait for `connected`, join as an API client, give the host a
# moment to set it up, then send commands. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"webAction","payload":{"action":"next","id":"a1"}}'
sleep 1
echo '{"type":"webAction","payload":{"action":"prev","id":"a2"}}'
sleep 1 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
const send = (message: object) => ws.send(JSON.stringify(message))
let ready = false
ws.onmessage = (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
send({ type: 'joinRoom', payload: { room: 'ABC123', presenterName: 'Show control', api: true } })
setInterval(() => ws.send('ping'), 15000) // keeps the connection open
}
if (msg.type === 'clicksEnabled' && msg.payload === true && !ready) {
ready = true
send({ type: 'webAction', payload: { action: 'next', id: 'a1' } })
// Also: { action: 'prev' }, { action: 'first' }, { action: 'last' },
// { action: 'goto', slide: 5 }, { action: 'blackout', on: true }
}
if (msg.type === 'controls') console.log('Commands this host supports:', ['next', 'prev', ...msg.payload.nav])
if (msg.type === 'webActionResult') console.log(`${msg.payload.action} delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'slide') console.log('Slide:', msg.payload?.number ?? 'no deck open')
if (msg.type === 'error') console.error(msg.error, msg.payload?.message)
}
import json
import threading
import time
import websocket
ready = False
def send(ws, message):
ws.send(json.dumps(message))
def keepalive(ws): # keeps the connection open
while ws.sock and ws.sock.connected:
time.sleep(15)
ws.send("ping")
def on_message(ws, message):
global ready
if message == "pong":
return
msg = json.loads(message)
if msg["type"] == "connected":
send(ws, {"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": True}})
threading.Thread(target=keepalive, args=(ws,), daemon=True).start()
elif msg["type"] == "clicksEnabled" and msg["payload"] is True and not ready:
ready = True
send(ws, {"type": "webAction", "payload": {"action": "next", "id": "a1"}})
# Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": True}, ...
elif msg["type"] == "webActionResult":
print(f"{msg['payload']['action']} delivered to {msg['payload']['hosts']} host(s)")
elif msg["type"] == "slide":
print("Slide:", msg["payload"]["number"] if msg["payload"] else "no deck open")
elif msg["type"] == "error":
print("Error:", msg["error"], msg["payload"]["message"])
ws = websocket.WebSocketApp(f"$WS_URL/ws?token={access_token}", on_message=on_message)
ws.run_forever()
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
conn, _, err := websocket.DefaultDialer.Dial("$WS_URL/ws?token="+accessToken, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
var writeMu sync.Mutex // one writer at a time
send := func(message interface{}) {
writeMu.Lock()
defer writeMu.Unlock()
conn.WriteJSON(message)
}
go func() { // keeps the connection open
for range time.Tick(15 * time.Second) {
writeMu.Lock()
conn.WriteMessage(websocket.TextMessage, []byte("ping"))
writeMu.Unlock()
}
}()
ready := false
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
if string(data) == "pong" {
continue
}
var msg struct {
Type string `json:"type"`
Error string `json:"error"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(data, &msg) != nil {
continue
}
switch msg.Type {
case "connected":
send(map[string]interface{}{
"type": "joinRoom",
"payload": map[string]interface{}{"room": "ABC123", "presenterName": "Show control", "api": true},
})
case "clicksEnabled":
if string(msg.Payload) == "true" && !ready {
ready = true
send(map[string]interface{}{
"type": "webAction",
"payload": map[string]interface{}{"action": "next", "id": "a1"},
})
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
case "webActionResult", "slide", "error":
fmt.Printf("%s %s %s\n", msg.Type, msg.Error, msg.Payload)
}
}
use WebSocket\Client;
use WebSocket\TimeoutException;
// A 15 second read timeout doubles as the keepalive timer.
$client = new Client("$WS_URL/ws?token={$accessToken}", ['timeout' => 15]);
$ready = false;
while (true) {
try {
$message = $client->receive();
} catch (TimeoutException $e) {
$client->text('ping'); // keeps the connection open
continue;
}
if ($message === null) break; // closed
if ($message === 'pong') continue;
$msg = json_decode($message, true);
if ($msg['type'] === 'connected') {
$client->text(json_encode([
'type' => 'joinRoom',
'payload' => ['room' => 'ABC123', 'presenterName' => 'Show control', 'api' => true]
]));
} elseif ($msg['type'] === 'clicksEnabled' && $msg['payload'] === true && !$ready) {
$ready = true;
$client->text(json_encode([
'type' => 'webAction',
'payload' => ['action' => 'next', 'id' => 'a1']
]));
// Also: ['action' => 'goto', 'slide' => 5], ['action' => 'blackout', 'on' => true], ...
} elseif (in_array($msg['type'], ['webActionResult', 'slide', 'error'], true)) {
echo json_encode($msg), PHP_EOL;
}
}
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::{connect_async, tungstenite::Message};
let url = format!("$WS_URL/ws?token={}", access_token);
let (ws_stream, _) = connect_async(&url).await?;
let (mut write, mut read) = ws_stream.split();
let every = std::time::Duration::from_secs(15);
let mut keepalive = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
let mut ready = false;
loop {
tokio::select! {
_ = keepalive.tick() => write.send(Message::Text("ping".into())).await?, // keeps the connection open
frame = read.next() => {
let Some(frame) = frame else { break };
let Message::Text(text) = frame? else { continue };
if text.as_str() == "pong" { continue }
let msg: Value = serde_json::from_str(&text)?;
match msg["type"].as_str() {
Some("connected") => {
let join = json!({"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": true}});
write.send(Message::Text(join.to_string().into())).await?;
}
Some("clicksEnabled") if msg["payload"] == true && !ready => {
ready = true;
let next = json!({"type": "webAction", "payload": {"action": "next", "id": "a1"}});
write.send(Message::Text(next.to_string().into())).await?;
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
Some("webActionResult") | Some("slide") | Some("error") => println!("{}", msg),
_ => {}
}
}
}
}
Résultats et événements
Chaque commande reçoit en réponse un webActionResult ou une error.
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts est le nombre d'hôtes auxquels la commande a été remise. La preuve de son exécution est le prochain événement slide de l'hôte ; blackout modifie aussi blackout dans le prochain événement controls de l'hôte. Une commande que l'hôte n'exécute pas (clics désactivés, commande non prise en charge, goto au-delà de la dernière diapositive) reçoit un résultat mais aucun événement slide.
error | Quand |
|---|---|
auth_required | joinRoom avec api: true sur une connexion sans jeton de session |
not_api_client | Une commande provenant d'une connexion qui n'a pas rejoint de salle avec api: true, ou qui l'a quittée |
invalid_web_action | action inconnue, ou slide, on ou id manquant ou invalide |
invalid_timer_action | action de minuteur inconnue, ou minutes ou id manquant ou invalide |
invalid_message | Un message sans text, ou de plus de 1000 caractères |
invalid_presenter_mouse | Une position du pointeur en dehors de l'intervalle 0 à 1 |
rate_limited | Plus de 10 commandes de diapositives et de minuteur en une seconde, ou plus de 5 messages en 10 secondes |
no_host | Aucun hôte n'est connecté à la salle |
host_update_required | Tous les hôtes de la salle utilisent une ancienne version de Cliqer qui ne peut pas recevoir de commandes de l'API |
api_not_in_plan | Le forfait du propriétaire de la salle n'inclut pas l'API client |
Les erreurs contiennent un message lisible et, lorsque la commande était bien formée, son action et son id :
{ "type": "error", "error": "invalid_web_action", "payload": { "message": "Unknown or incomplete webAction. Use next, prev, first, last, goto with slide, or blackout with on.", "action": "goto", "id": "a3" } }
Les événements provenant d'un hôte portent l'identifiant de cet hôte dans hostId :
| Événement | Charge utile |
|---|---|
clicksEnabled | true lorsque l'hôte accepte vos commandes, false lorsque l'opérateur a désactivé vos clics |
controls | { app, nav, blackout, timer } : l'application de présentation, les commandes de diapositives supplémentaires qu'elle prend en charge (first, last, goto, blackout), si l'écran est au noir, et si l'hôte accepte les commandes de minuteur |
slide | { number, current, total, title, notes } après chaque commande exécutée par l'hôte (number vaut "4/12", total vaut null lorsque l'application ne le communique pas), ou null lorsque l'hôte n'a aucune présentation ouverte |
countdown | Le temps affiché par le minuteur de l'hôte, par exemple "4:59" ("-0:12" en dépassement), chaque seconde pendant qu'il tourne, ou null lorsqu'il s'arrête |
countdownPhase | { phase, blink } : phase vaut idle, talk, warning (moins d'une minute restante), overtime ou paused, et blink indique si le minuteur de l'hôte clignote |
messageFromHost | Une réponse de l'opérateur de l'hôte, voir Messages à l'hôte |
Vous recevez aussi les événements propres à la salle (clientJoined, clientLeft et d'autres) ; ignorez ceux dont vous n'avez pas besoin. Si l'opérateur retire ou bloque votre connexion, vous recevez logout avec reason: "kicked".
Minuteur
Pilotez le minuteur de l'application Cliqer de l'hôte avec les mêmes commandes que celles qu'envoie un Stream Deck ou Bitfocus Companion.
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | Effet |
|---|---|
start | Démarrer le minuteur, ou le reprendre après stop |
stop | Mettre le minuteur en pause |
reset | Arrêter le minuteur et revenir à la durée réglée |
plus1, plus2, plus5 | Ajouter 1, 2 ou 5 minutes |
minus1, minus2, minus5 | Retirer 1, 2 ou 5 minutes |
preset5, preset10, preset15, preset20 | Arrêter le minuteur et régler 5, 10, 15 ou 20 minutes |
set | Arrêter le minuteur et régler minutes, un nombre entier de 1 à 999 |
- Chaque commande reçoit en réponse un
timerActionResult({ action, id, hosts },hostscomme pour les diapositives) ou uneerror. La preuve de son exécution est le prochain événementcountdownoucountdownPhasede l'hôte. - Le minuteur ne fonctionne que sur un hôte dont le dernier événement
controlscontienttimer: true. Les hôtes équipés d'une ancienne version de Cliqer ne l'annoncent pas et ignorent les commandes de minuteur. - Les commandes de minuteur suivent les mêmes règles que les commandes de diapositives : l'hôte ne les exécute que tant qu'il autorise les clics de votre connexion, et elles partagent le quota de 10 commandes par seconde.
- Les réglages du minuteur de l'hôte s'appliquent tels quels : un minuteur réglé en mode progressif compte vers le haut, et un minuteur qui suit un minuteur matériel connecté prend son temps sur cet appareil.
Pointeur laser
Affichez un point laser sur l'écran partagé de l'hôte, comme le fait le téléphone d'un présentateur.
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xetyindiquent la position sur l'écran partagé à partir de son coin supérieur gauche, de0à1.- Envoyez
{ "pressed": false }comme charge utile pour masquer le point. Sinon, il disparaît 30 à 90 secondes après votre dernière position. - Le point ne s'affiche que lorsque l'hôte partage un écran et que Pointeur laser est activé. Sa couleur dépend de votre
presenterName. - Envoyez au maximum 30 positions par seconde. Les positions excédentaires sont ignorées sans erreur, et il n'y a aucun événement de résultat.
Messages à l'hôte
Envoyez un message à l'opérateur de l'hôte. Il apparaît dans le chat des présentateurs de l'hôte, comme un message envoyé depuis le téléphone d'un présentateur, et les réponses de l'opérateur reviennent sous forme de messageFromHost.
# Join as an API client, then send a message. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"messageToHosts","payload":{"text":"Two minutes to the break","id":"m1"}}'
sleep 30 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
// After joining (see Slide Navigation): send a message, print the replies.
send({ type: 'messageToHosts', payload: { text: 'Two minutes to the break', id: 'm1' } })
ws.addEventListener('message', (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'messageToHostsResult') console.log(`Delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'messageFromHost') console.log(`${msg.payload.fromPresenterName}: ${msg.payload.originalPayload.text}`)
})
# After joining (see Slide Navigation): send a message.
send(ws, {"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}})
# In on_message, print the replies:
# elif msg["type"] == "messageFromHost":
# print(f"{msg['payload']['fromPresenterName']}: {msg['payload']['originalPayload']['text']}")
// After joining (see Slide Navigation): send a message.
send(map[string]interface{}{
"type": "messageToHosts",
"payload": map[string]interface{}{"text": "Two minutes to the break", "id": "m1"},
})
// In the read loop, print the replies:
// case "messageFromHost":
// fmt.Printf("%s\n", msg.Payload)
// After joining (see Slide Navigation): send a message.
$client->text(json_encode([
'type' => 'messageToHosts',
'payload' => ['text' => 'Two minutes to the break', 'id' => 'm1']
]));
// In the read loop, print the replies:
// } elseif ($msg['type'] === 'messageFromHost') {
// echo $msg['payload']['originalPayload']['text'], PHP_EOL;
// After joining (see Slide Navigation): send a message.
let message = json!({"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}});
write.send(Message::Text(message.to_string().into())).await?;
// In the read loop, print the replies:
// Some("messageFromHost") => println!("{}", msg["payload"]["originalPayload"]["text"]),
Réponse de l'hôte :
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
textcomprend de 1 à 1000 caractères. Envoyez au maximum 5 messages toutes les 10 secondes.- Chaque message reçoit en réponse un
messageToHostsResult({ id, hosts }) ou uneerror. - L'opérateur voit le message sous votre
presenterName, et l'hôte affiche une notification lorsque son réglage Notifications est activé.
Référence des événements
Événements sortants (client → serveur)
| Événement | Description |
|---|---|
joinRoom | Rejoindre une salle en tant que client API avec api: true (voir Navigation dans les diapositives) |
webAction | Contrôle des diapositives (voir Navigation dans les diapositives) |
timerAction | Contrôle du minuteur (voir Minuteur) |
presenterMouse | Position du pointeur laser (voir Pointeur laser) |
messageToHosts | Envoyer un message à l'opérateur de l'hôte (voir Messages à l'hôte) |
logout | Quitter la salle ; la connexion reste ouverte et peut rejoindre à nouveau une salle |
Événements entrants (serveur → client)
| Événement | Description |
|---|---|
connected | Envoyé à l'ouverture avec { clientId, userId, isAuthenticated } |
joinedRoom | Salle rejointe avec succès |
clientJoined | Quelqu'un a rejoint la salle |
clientLeft | Quelqu'un a quitté la salle |
webActionResult | Un webAction a été remis aux hôtes de la salle |
timerActionResult | Un timerAction a été remis aux hôtes de la salle |
messageToHostsResult | Un messageToHosts a été remis aux hôtes de la salle |
clicksEnabled | Indique si un hôte accepte vos commandes |
controls | Ce que prennent en charge l'application de présentation et le minuteur d'un hôte |
slide | La diapositive actuelle d'un hôte après une commande |
countdown | Le temps affiché par le minuteur d'un hôte |
countdownPhase | La phase du minuteur d'un hôte |
messageFromHost | Un message de l'opérateur de l'hôte |
logout | L'opérateur a retiré ou bloqué votre connexion (reason: "kicked") |
error | Message d'erreur |