~fabrixxm/sito

997bbbc19a64e2cf6b4ff88712c7e4e29b150731 — fabrixxm 4 months ago a9d8d31
Articolo video-sincrono-pt-1
1 files changed, 464 insertions(+), 0 deletions(-)

A content/articoli/video-sincrono-pt-1.md
A content/articoli/video-sincrono-pt-1.md => content/articoli/video-sincrono-pt-1.md +464 -0
@@ 0,0 1,464 @@
Title: Cinema web in javascript - Parte 1
Date: 2022-02-10 16:00
Author: fabrix
Category: Articoli

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](https://github.com/binwiederhier/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/](https://ntfy.sh/)

## Scrviamo del codice

Cominciamo con la nostra pagina:

```html
<!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`:

```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:

```js
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:

```js
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:

```js
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.

```js
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:

```js
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`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource).
`EventSource` è un oggetto del browser che implementa un client [Server-side events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_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:

```js
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:

```js
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.

```js
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`:

```js
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:

```js
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](https://en.wikipedia.org/wiki/URI_fragment).

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

```js
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:

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

aggiungiamo una nuova variabile

```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");
// 👇 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.

```js
            ...
            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`.

```js
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](https://paste.sr.ht/~fabrixxm/31ec6c649eba7ee3b803a84d3382517d37dcad6d)