WebSocket in tempo reale

Connettiti a wss://cliqer.io/ws per controllare una stanza Cliqer dal tuo software o dal tuo sistema di show control, come un telecomando per presentazioni: cambia slide, gestisci il timer dell'host, punta con un puntino laser e invia messaggi all'host. La tua app entra nella stanza come relatore; la stanza è sempre ospitata dall'app desktop Cliqer.
session_token (i browser lo inviano automaticamente), un parametro di query ?token= o un header Authorization: Bearer. Il token viene verificato al momento della connessione, non a ogni comando, quindi quando scade apri una nuova connessione con un token nuovo ed entra di nuovo nella stanza. Senza token puoi connetterti, ma non puoi usare l'API. Le chiavi API del team (cliqer_...) autenticano solo gli endpoint REST; su questo socket una chiave non è un token di sessione.Connessione
# 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);
}
_ => {}
}
}
Il server invia connected con il tuo clientId una volta configurata la connessione. Attendilo prima di inviare qualsiasi cosa: un messaggio che arriva prima viene rifiutato con un error e la connessione viene chiusa.
Flusso di connessione e stanza
Navigazione delle slide
Controlla le slide di una stanza dal tuo software, come un telecomando per presentazioni. La tua app entra nella stanza come relatore, quindi l'host la vede nell'elenco dei relatori con il suo presenterName e la gestisce come qualsiasi altro relatore: l'operatore può disattivarne i clic, rimuoverla o bloccarla.
- Connettiti con un token di sessione (
?token=, un headerAuthorization: Bearero il cookiesession_token). Le chiavi API del team funzionano solo sugli endpoint REST, non su questo socket. - Il piano del proprietario della stanza deve includere la Client API: l'add-on API su Presenter, Studio HD e Studio 4K, inclusa in Studio 8K ed Enterprise.
- I comandi per slide e timer vengono eseguiti solo mentre l'host consente i clic dalla tua connessione.
Entra nella stanza
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- Invialo dopo l'evento
connected: il server rifiuta qualsiasi cosa arrivi prima. presenterNameè il nome che vede l'host (predefinitoAPI). La tua app è sempre un relatore nella stanza, mai il suo host.- Attendi un evento
clicksEnabledcon payloadtrueprima di inviare comandi. L'host lo invia una volta configurata la tua connessione, e di nuovo ogni volta che l'operatore attiva o disattiva i tuoi clic. - Conserva la
presenterKeyricevuta injoinedRoome inviala injoinRoomquando ti riconnetti, così l'host mantiene il tuo permesso di clic. - Invia il frame di testo
pingogni 15 secondi per mantenere aperta la connessione. Il server risponde con il frame di testopong(non JSON).
Invia i comandi
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | Campo aggiuntivo | Cosa fa |
|---|---|---|
next | Slide successiva (o animazione successiva) | |
prev | Slide precedente (o animazione precedente) | |
first | Prima slide | |
last | Ultima slide | |
goto | slide: numero intero da 1 a 9999 | Vai a quella slide |
blackout | on: true o false | Oscura lo schermo o mostralo di nuovo |
idè facoltativo: da 1 a 64 lettere, cifre,_,.,:o-. Viene restituito nel risultato. Usa un nuovoidper ogni comando: l'host scarta un comando il cuiidha già ricevuto dalla tua connessione negli ultimi 5 secondi, quindi un nuovo tentativo dello stesso comando viene eseguito una sola volta.first,last,gotoeblackoutvengono eseguiti solo su un host il cui eventocontrolspiù recente li elenca innav. Un host che non invia alcun eventocontrolssupporta solonexteprev. Con più host nella stanza, ogni host esegue i comandi che supporta.nextoprevcon lo schermo oscurato lo mostrano di nuovo senza cambiare slide.- Un
gotooltre l'ultima slide viene ignorato. - Invia al massimo 10 comandi al secondo, contando insieme i comandi per slide e timer. L'host scarta anche una ripetizione dello stesso comando entro 50 millisecondi.
Cosa supporta oggi ogni app di presentazione:
| App di presentazione | next, prev | first, last | goto | blackout |
|---|---|---|---|---|
| PowerPoint su Windows | Sì | Sì | Sì | Sì |
| PowerPoint su Mac | Sì | Sì | No | No |
| Keynote | Sì | No | No | Sì |
| Google Slides (estensione del browser connessa) | Sì | Sì | No | No |
| vMix | Sì | No | No | No |
| Finestra in primo piano | Sì | No | No | No |
L'evento controls è la risposta in tempo reale per l'host che stai controllando: leggi nav da lì anziché da questa tabella.
# 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),
_ => {}
}
}
}
}
Risultati ed eventi
Ogni comando riceve in risposta un webActionResult o un error.
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts è il numero di host a cui il comando è stato consegnato. La prova che è stato eseguito è il successivo evento slide dell'host; blackout modifica anche blackout nel successivo evento controls dell'host. Un comando che l'host non esegue (clic disattivati, un comando che non supporta, un goto oltre l'ultima slide) riceve un risultato ma nessun evento slide.
error | Quando |
|---|---|
auth_required | joinRoom con api: true su una connessione senza token di sessione |
not_api_client | Un comando da una connessione che non è entrata in una stanza con api: true, o dopo che l'ha lasciata |
invalid_web_action | action sconosciuta, oppure slide, on o id mancante o non valido |
invalid_timer_action | action del timer sconosciuta, oppure minutes o id mancante o non valido |
invalid_message | Un messaggio senza text o più lungo di 1000 caratteri |
invalid_presenter_mouse | Una posizione del puntatore fuori dall'intervallo da 0 a 1 |
rate_limited | Più di 10 comandi per slide e timer in un secondo, o più di 5 messaggi in 10 secondi |
no_host | Nessun host è connesso alla stanza |
host_update_required | Tutti gli host nella stanza usano una versione di Cliqer meno recente che non accetta comandi dall'API |
api_not_in_plan | Il piano del proprietario della stanza non include la Client API |
Gli errori contengono un message leggibile e, quando il comando era ben formato, la sua action e il suo 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" } }
Gli eventi di un host riportano l'id di quell'host in hostId:
| Evento | Payload |
|---|---|
clicksEnabled | true quando l'host accetta i tuoi comandi, false quando l'operatore ha disattivato i tuoi clic |
controls | { app, nav, blackout, timer }: l'app di presentazione, i comandi aggiuntivi per le slide che supporta (first, last, goto, blackout), se lo schermo è oscurato e se l'host accetta comandi per il timer |
slide | { number, current, total, title, notes } dopo ogni comando eseguito dall'host (number è "4/12", total è null quando l'app non lo comunica), oppure null quando l'host non ha alcuna presentazione aperta |
countdown | Il tempo mostrato dal timer dell'host, ad esempio "4:59" ("-0:12" a tempo scaduto), ogni secondo mentre è in funzione, oppure null quando si ferma |
countdownPhase | { phase, blink }: phase è idle, talk, warning (meno di un minuto rimanente), overtime o paused, e blink indica se il timer dell'host sta lampeggiando |
messageFromHost | Una risposta dell'operatore dell'host, vedi Messaggi all'host |
Ricevi anche gli eventi propri della stanza (clientJoined, clientLeft e altri); ignora quelli che non usi. Se l'operatore rimuove o blocca la tua connessione, ricevi logout con reason: "kicked".
Timer
Gestisci il timer nell'app Cliqer dell'host con gli stessi comandi che invia uno Stream Deck o Bitfocus Companion.
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | Cosa fa |
|---|---|
start | Avvia il timer o riprendilo dopo stop |
stop | Metti in pausa il timer |
reset | Ferma il timer e torna al tempo impostato |
plus1, plus2, plus5 | Aggiungi 1, 2 o 5 minuti |
minus1, minus2, minus5 | Togli 1, 2 o 5 minuti |
preset5, preset10, preset15, preset20 | Ferma il timer e imposta 5, 10, 15 o 20 minuti |
set | Ferma il timer e imposta minutes, un numero intero da 1 a 999 |
- Ogni comando riceve in risposta un
timerActionResult({ action, id, hosts }, conhostscome per le slide) o unerror. La prova che è stato eseguito è il successivo eventocountdownocountdownPhasedell'host. - Il timer funziona solo su un host il cui evento
controlspiù recente contienetimer: true. Gli host con una versione di Cliqer meno recente non lo annunciano e ignorano i comandi per il timer. - I comandi per il timer seguono le stesse regole dei comandi per le slide: l'host li esegue solo mentre consente i clic dalla tua connessione, e condividono il limite di 10 comandi al secondo.
- Le impostazioni del timer dell'host si applicano così come sono: un timer impostato per contare in avanti conta in avanti, e un timer che segue un timer hardware connesso prende il tempo da quel dispositivo.
Puntatore laser
Mostra un puntino laser sullo schermo condiviso dell'host, come fa il telefono di un relatore.
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xeysono la posizione sullo schermo condiviso a partire dall'angolo in alto a sinistra, da0a1.- Invia
{ "pressed": false }come payload per nascondere il puntino. Altrimenti scompare da 30 a 90 secondi dopo la tua ultima posizione. - Il puntino compare solo mentre l'host condivide uno schermo e ha Puntatore laser attivo. Il suo colore dipende dal tuo
presenterName. - Invia al massimo 30 posizioni al secondo. Le posizioni in eccesso vengono scartate senza errore e non c'è alcun evento di risultato.
Messaggi all'host
Invia un messaggio all'operatore dell'host. Compare nella chat dei relatori dell'host, come un messaggio inviato dal telefono di un relatore, e le risposte dell'operatore tornano come 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"]),
Risposta dell'host:
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
textè lungo da 1 a 1000 caratteri. Invia al massimo 5 messaggi ogni 10 secondi.- Ogni messaggio riceve in risposta un
messageToHostsResult({ id, hosts }) o unerror. - L'operatore vede il messaggio con il tuo
presenterNamee l'host mostra una notifica quando l'impostazione Notifiche è attiva.
Riferimento degli eventi
Eventi in uscita (Client → Server)
| Evento | Descrizione |
|---|---|
joinRoom | Entra in una stanza come client API con api: true (vedi Navigazione delle slide) |
webAction | Controllo delle slide (vedi Navigazione delle slide) |
timerAction | Controllo del timer (vedi Timer) |
presenterMouse | Posizione del puntatore laser (vedi Puntatore laser) |
messageToHosts | Invia un messaggio all'operatore dell'host (vedi Messaggi all'host) |
logout | Lascia la stanza; la connessione resta aperta e può entrare di nuovo |
Eventi in entrata (Server → Client)
| Evento | Descrizione |
|---|---|
connected | Inviato all'apertura con { clientId, userId, isAuthenticated } |
joinedRoom | Ingresso nella stanza riuscito |
clientJoined | Qualcuno è entrato nella stanza |
clientLeft | Qualcuno ha lasciato la stanza |
webActionResult | Una webAction è stata consegnata agli host della stanza |
timerActionResult | Una timerAction è stata consegnata agli host della stanza |
messageToHostsResult | Un messageToHosts è stato consegnato agli host della stanza |
clicksEnabled | Se un host accetta i tuoi comandi |
controls | Cosa supportano l'app di presentazione e il timer di un host |
slide | La slide corrente di un host dopo un comando |
countdown | Il tempo sul timer di un host |
countdownPhase | La fase del timer di un host |
messageFromHost | Un messaggio dall'operatore dell'host |
logout | L'operatore ha rimosso o bloccato la tua connessione (reason: "kicked") |
error | Messaggio di errore |