Proviamo a scrivere un'applicazione web che permette di vedere un video con gli amici e di commentarlo via chat.

L'idea è di avere un lettore video in una pagina web che carica un file video da un url e che sia controllato da uno dei partecipanti.

Avremo quindi un utente (che chiameremo 'controllore') crea una "stanza", decide l'url del video che sarà riprodotto e che controlla il lettore video. Gli altri utenti quando si connettono alla stanza vedranno caricarsi il video scelto e la riproduzione sarà sincrona con quella del 'controllore', che invierà una serie di messaggi per far sapere se è in pausa o in play, e a che posizione del video si trova.

Abbiamo quindi bisogno di un sistema per inviare e ricevere messaggi tra diversi computer attraverso javascript.

Potremmo scrivere un server che distribuisce i messaggi ai vari client collegati via websocket, ma scrivere e mettere online un server simile è troppo complicato per ora.

Potremmo utilizzare webrtc e mettere in comunicazione i browser tra di loro, ma serve comunque un sistema per inizializzare le connessioni, attraverso un software lato server (e siamo come sopra) o attraverso un canale di comunicazione alternativo (per esempio scambiandosi delle stringhe di connessione via email o via chat esterne), ma è scomodo. Per di più il codice javascript per gestire tutto questo diventa alquanto complicato.

L'idea per questo esperimento è di utilizzare ntfy che è un server di pub-sub basato su http: messaggi inviati a un "topic" con una richiesta HTTP POST vengono girati a tutti i client in ascolto su quel topic. E siccome per questo esperimento non vogliamo gestire niente lato server, utilizzeremo il servizio gestito dallo stesso progetto: https://ntfy.sh/

Scrviamo del codice

Cominciamo con la nostra pagina:

<!DOCTYPE html>
<html>
    <style>
        .hidden { display: none; }
    </style>
    <body>
        <div id="start">
            <p>
                <label for="video_url">URL video:</label>
                <input id="video_url">
            </p>
            <button id="start_button">Start</button>
        </div>


        <video id="player" class="hidden" controls></video>

        <a id="room_link" href="#"></a>

    </body>
    <script src="app.js"></script>
</html>

Una pagina molto semplice: abbiamo un campo di testo un cui inserire l'indirizzo del video da riprodurre e il lettore video, che teniamo nascosto finchè l'utente non pigia "Start".

Il codice della nostra applicazione è tutto nel file app.js:

let e_start_form = document.getElementById("start");
let e_video_url = document.getElementById("video_url");
let e_start_button = document.getElementById("start_button");
let e_player = document.getElementById("player");
let e_room_link = document.getElementById("room_link");

iniziamo salvandoci un riferimento agli elementi nella pagina che dovremo modificare.

Definiamo poi un po' di variabili:

let topic = "";             // topic su ntfy che useremo per inviare e ricevere i messaggi
let video_url = "";         // l'url del video che stiamo vedendo
let last_time = -1;         // questo è l'ultima posizione nel video che abbiamo notificato
let player_state = "stop";  // stato del lettore, "stop" "play" "pause"
let im_control = false;     // siamo noi i controllori nella stanza?

const MIN_T = 5;            // tempo minimo tra le notifiche

let eventSource = null;

Teniamo traccia del topic (che è una stringa di testo e che genereremo casualmente), e dell'url del video. Il lettore ci notifica a ogni cambio della posizione nel video. Se inviamo un messaggio per ogni aggiornamento finisce che generiamo troppo traffico inutile, quindi teniamo traccia dell'ultima posizione inviata e quando la nostra posizione cambia più di un tot di secondi, allora inviamo una nuova notifica. Questa quantità è definita nella costante MIN_T, qui definita a 5 secondi. Ovviamente le notifiche le inviamo solo se siamo i controllori, e siamo i controllori se abbiamo creato noi la stanza, nel qual caso la variabile im_control sarà true.

L'utima variabile, eventSource la vediamo dopo, è l'oggetto che riceve i messaggi da ntfy.

Ora definiamo qualche funzione.

Per prima la funzione che invia i messaggi:

function send_message_sync() {
    if (!im_control) return;
    const ct = e_player.currentTime;
    last_time = ct;

    let data = {
        'type':'sync',
        'url': video_url,
        'state': player_state,
        'currenttime': ct,
    }

    fetch('https://ntfy.sh/' + topic, {
        method: 'POST',
        body: JSON.stringify(data),
    });
}

Ntfy prende solo testo, quindi nella funzion costruiamo un messaggio e lo convertiamo in JSON per inviarlo al topic con una richiesta POST.

Il messaggio conterrà:

  • il tipo di messaggio
  • l'url del video che stiamo guardando
  • lo stato del lettore
  • la posizione del lettore in secondi

Il tipo di messaggio è sync, ed è l'unico che vedremo per ora. In una versione più avanzata la pagina conterrà anche una chat, quindi ci sarà anche un messaggio di tipo chat.

Poi definiamo la funzione che crea una nuova stanza:

function create_room() {
    video_url = get_required_value(e_video_url);
    if (!video_url) return;

    let room_id =  Math.random().toString(16).substr(2, 20);

    im_control = true;

    start(room_id);
}

Qui recuperiamo il valore inserito nel campo di testo, che non deve essere vuoto, nel caso usciamo subito dalla funzione.

get_required_value() è una funzione di supporto che controlla che il campo non sia vuoto e nel caso imposta il campo stesso come non valido.

function get_required_value(e) {
    let v = e.value;
    if (v == "") {
        e.setCustomValidity("Field required.");
        return null;
    }
    e.setCustomValidity("");
    return v;
}

Tornando a create_room(), generiamo un nuovo id random e impostiamo im_control a true: l'utente che crea la stanza è il controllore.

Chiamiamo quindi start() che avvia la stanza:

function start(room_id) {
    topic = room_id;

    e_start_form.classList.add("hidden");
    e_player.classList.remove("hidden");

    e_room_link.href = "#" + topic;
    e_room_link.innerText = topic;

    eventSource = new EventSource('https://ntfy.sh/' + topic + '/sse');
    eventSource.addEventListener('message', on_message);

    if (video_url !== "") {
        e_player.src = video_url;
    }
}

Il room_id diventa il topic che useremo con ntfy.

Nascondiamo la form e mostriamo il player video, e aggiorniamo il link chiamato room_link in modo che l'utente possa copiare il link diretto alla stanza, compreso del topic. Il link sarà http//server/percorso/#topic.

Creiamo poi l'oggetto EventSource. EventSource è un oggetto del browser che implementa un client Server-side events. Una volta connesso, viene mantenuta la connessione aperta e ogni volta che il server genera un nuovo messaggio l'oggetto emette l'evento message che noi gestiamo nella funzione on_message() che vediamo sotto.

Questo ci permette di ricevere da ntfy i messaggi che il controllore invia al topic.

Per concludere la funzione, se è stato definito un url video, lo facciamo caricare al lettore.

Per chiudere il funzionamento della pagina per il controllore, gestiamo un po' di eventi.

Quando l'utente clicca su "Start", creiamo la stanza:

e_start_button.addEventListener("click", create_room);

Quando la posizione del lettore nel video cambia, emette l'evento 'timeupdate', se il tempo corrente è diverso di più di MIN_T, inviamo il messaggio di sync:

e_player.addEventListener("timeupdate", (event) => {
    if ( Math.abs(last_time - e_player.currentTime) > MIN_T ) send_message_sync();
});

Aggiorniamo lo stato del lettore quando passa a play o pausa, inviano un messaggio di sync. Inviamo "stop" quando il video finisce.

e_player.addEventListener("play", (e) => { 
    player_state = "play"; 
    send_message_sync();
});
e_player.addEventListener("pause", (e) => { 
    player_state = "pause";
    send_message_sync();
});
e_player.addEventListener("ended", (e) => { 
    player_state = "stop"; 
    send_message_sync(); 
});

E con questo abbiamo terminato la parte di codice che riguarda il controllore. Ora vediamo cosa ci serve per far funzionare gli altri partecipanti.

E gli spettatori?

Inanzitutto dobbiamo ricevere i messaggi inviati dal controllore e reagire di conseguenza. Quindi definiamo la funzione on_message() che gestisce l'evento message dell'oggetto EventSource:

function on_message(e) {

}

il parametro e è un oggetto che contiene i dettagli dell'evento. In questo caso ci sarà una proprietà data che sarà il json ricevuto dal server. Questo json contiene a sua volta una proprietà message che contiene il messaggio che il controllore ha effettivamente inviato al topic.

Una volta estratto quello possiamo reagire di conseguenza:

function on_message(evt) {
    let data = JSON.parse(evt.data);
    data = JSON.parse(data.message);

    switch(data.type) {
        case "sync":
            // il messaggio è di tipo sync
            // se sono il controllore, ho inviato io il messaggio,
            // non ho bisogno di reagire.
            if (im_control) return;

            // se il lettore video ha un indirizzo diverso da quello
            // nel messaggio, dobbiamo aggiornarlo
            // Questo permette agli spettatori di caricare il video
            // la prima volta che ricevono un messaggio di sync
            if (data.url != e_player.currentSrc) e_player.src = data.url;

            // se il mio lettore video è più di MIN_T lontano dalla posizione
            // del messaggio allora mi riposiziono.
            // questo lascia al player un po' di gioco per gestire ritardi di
            // buffering senza essere sempre spostato mentre cerca di recuperare
            // nuovi dati video.
            if (Math.abs(data.currenttime - e_player.currentTime) > MIN_T) {
                e_player.currentTime = data.currenttime;
            }

            // se lo stato del player del controllore è diverso da quello locale
            // aggiorniamoci!
            // se arriva lo stato stop, "scarico" il video dal lettore
            // impostando un url vuoto
            if (data.state != player_state) {
                switch (data.state) {
                    case "pause":
                        e_player.pause();
                        break;
                    case "play":
                        e_player.play();
                        break;
                    case "stop":
                        e_player.src = "";
                        break;
                }
            }

            break;
    }
}

L'utente spettatore per entrare nella chat deve utilizzare il link che riporta l'id della stanza come fragment.

Quindi al caricamento della pagina controlliamo se c'è un id nell'url, e nel caso avviamo la stanza:

let hash = window.location.hash.replace("#", "");
if (hash != "") {
    start(hash);
}

L'api javascript lo chiama hash. Il valore riporta anche il # iniziale, che togliamo. Quello che resta, se c'è, è l'id della nostra stanza, che quindi passiamo a start().

Rendiamo felice il browser

A questo punto abbiamo la possibiltà di creare una stanza con un player che sarà sincronizzato tra i vari spettatori, più o meno qualche secondo (ma nelle mie prove riesce ad essere abbastanza preciso).

Quando uno spettatore entra nella stanza il player non ha sorgente che verrà caricata al primo messaggio di sync.

Se il video si trova su un dominio differente, il fatto di caricare l'url via javascript potrebbe far scattare qualche estensione blocca-pubblicità. Non possiamo fare molto, salvo servire il file video dallo stesso dominio su cui si trova la pagina.

Al primo messaggio di sync che arriva con stato play, invochiamo e_player.play(). Questo fa sciuramente scattare la protezione del browser che blocca l'autoplay dei video. L'utente deve eseguire un'azione sul browser che porta a far partire il video, ma nel nostro caso non è così, e quindi viene bloccato.

Per bypassare il problema, aggiungiamo un div con un messaggio di avviso nell'html, giusto sotto il player video, e lo teniamo nascosto:

...
        <video id="player" class="hidden" controls>
        <!--👇 questo è nuovo -->
        <div id="alert" class="hidden">Per avviare il video premi "play"</div>
...

aggiungiamo una nuova variabile

let e_start_form = document.getElementById("start");
let e_video_url = document.getElementById("video_url");
let e_start_button = document.getElementById("start_button");
let e_player = document.getElementById("player");
let e_room_link = document.getElementById("room_link");
// 👇 questo è nuovo
let e_alert = document.getElementById("alert");

e modifichiamo la funzione on_message().

Ora se lo stato corrente locale è stop (stato in cui si trova lo script all'avvio), e il messaggio di sync riporta play mostriamo il messaggio di avviso.

            ...
            if (data.url != e_player.currentSrc) e_player.src = data.url;

            // 👇 questo è nuovo
            // il browser ci impedisce di passare da "stop" a "play" la prima volta
            // usando javascript, quindi mostriamo il messaggio di avviso
             if (player_state == "stop" && data.state == "play") {
                e_alert.classList.remove("hidden");
                return;
            }

            ...

E per terminare, nasconiamo il messaggio di avviso quando il lettore passa in play.

e_player.addEventListener("play", (e) => { 
    player_state = "play"; 
    send_message_sync();
    // 👇 questo è nuovo
    e_alert.classList.add("hidden");
});

Conlusioni e note

Ovviamente il codice qui descritto è pittosto semplice e non gestisce eventuali errori, così come non tiene conto di alcuni casi 'strani'.

Per esempio, nel caso il file sorgente sia un mp4 "frammentato", il browser non puo' sapere quanto è lungo tutto il video, ma solo quanto è lungo fino al frammento caricato. Diciamo che ogni frammento è un minuto di video, appena caricato il video il browser pensa che tutto il video duri un minuto. Poi a una successiva richiesta, riceve il frammento successivo ed ora pensa che duri due minuti.

Diciamo che il nostro "controllore" è a 1m39s, quindi già nel secondo frammento, quando uno spettatore si collega.

Appena collegato lo spettatore riceve un messaggio di sync che gli da l'url da caricare e lo fa saltare a 1m39s. Pero' il browser dello spettatore ha fin'ora caricato solo il primo frammento e quindi pensa che il video duri solo un minuto. Quindi il lettore salterà alla fine del frammento e andrà in stop, e il browser smetterà di chiedere nuovi frammenti, lasciando lo spettatore con il video fermo che non prosegue.

Vi lascio il piacere di risolvere questi problemi.

Un'altra cosa da notare è che utilizzare così ntfy.sh non è bello, ed è facile incappare nei limiti di richieste. Ma è comunque possibile installarsi il proprio server ntfy e farci ciò che si vuole.

Per ultimo, è chiaro che abbiamo accuratamente evitato di parlare di CSS, salvo per ciò che è utile al funzionamento (come la classe .hidden). Di CSS non ne parleremo proprio, lascio a voi il piacere di creare una veste grafica interessante.

Nel prossimo articolo proviamo ad aggiungere una chat.

Il codice scritto fin'ora lo trovate qui