Skip to content

Exemple — quiz avec page viewers (plugin) ​

L'appli pose une question ; les viewers répondent depuis leur page plugin ; l'appli compte une réponse par identifiant Twitch. Une nouvelle réponse remplace la précédente jusqu'à la fermeture du vote. Aucune récompense ni écriture persistante dans cet exemple.

Pars du bootstrap courant : conserve index.html, style.css et les configurations. Fusionne le manifeste ci-dessous, remplace le script.js de l'appli, puis ajoute viewers/index.html et viewers/script.js. Les deux scripts s'exécutent dans des pages différentes : kapp dans l'appli, KappappsHost dans le plugin.

infos.json (extrait à fusionner) ​

json
{
    "launch-mode": "external",
    "app-index": "index.html",
    "viewers-plugin": "viewers",
    "viewers-plugin-index": "index.html",
    "viewers-plugin-height": 400,
    "scopes": ["app-plugin-viewers:write"]
}

viewers-plugin désigne un dossier, son index un fichier relatif à ce dossier. Le scope autorise les diffusions et les confirmations envoyées aux viewers. Aucun scope chat n'est nécessaire.

script.js (appli, après AppliLoaded) ​

js
(async function () {
    const state = {
        questionId: String(Date.now()),
        question: 'Quelle est la capitale de la France ?',
        choices: ['Paris', 'Lyon', 'Marseille'],
        closed: false,
    };
    const answers = new Map(); // viewer.id authentifié -> choix
    const heading = document.createElement('h1');
    heading.textContent = state.question;
    const results = document.createElement('pre');
    document.body.replaceChildren(heading, results);
    kapp.display();

    const onViewer = ({viewer, event, data, reply}) => {
        void (async () => {
            if (event === 'quiz-state') {
                if (reply) await reply({...state}); // arrivée après la diffusion initiale
                return;
            }
            if (event !== 'answer') return;
            if (state.closed || data?.questionId !== state.questionId || !state.choices.includes(data?.choice)) {
                if (reply) await reply({ok: false, reason: 'closed_or_invalid'});
                return;
            }
            answers.set(viewer.id, data.choice); // ne jamais croire un id fourni dans data
            if (reply) await reply({ok: true}); // confirmation après traitement
        })().catch(error => console.error('Réponse plugin non transmise', error));
    };

    kapp.plugin.events.on(kapp.plugin.EVENTS.PLUGIN_VIEWERS, onViewer);
    try {
        await kapp.plugin.emitViewers(null, 'quiz-state', {...state});
        await wait(30000);
        state.closed = true;
        const tally = Object.fromEntries(state.choices.map(choice => [choice, 0]));
        for (const choice of answers.values()) tally[choice]++;
        results.textContent = JSON.stringify(tally, null, 2);
        await kapp.plugin.emitViewers(null, 'quiz-state', {...state});
        await wait(5000);
    } finally {
        kapp.plugin.events.off(kapp.plugin.EVENTS.PLUGIN_VIEWERS, onViewer);
    }
    await kapp.finish();
})().catch(async error => {
    console.error('Quiz interrompu', error);
    await kapp.abort('Quiz indisponible');
});

viewers/index.html ​

html
<!doctype html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Quiz viewers</title>
    <script src="https://kappapps.app/assets/app-plugin/helper-v3.js" defer></script>
    <script src="./script.js" defer></script>
</head>
<body>
    <h1 id="question">Chargement du quiz…</h1>
    <div id="choices"></div>
    <p id="status" role="status"></p>
    <button id="refresh" type="button">Actualiser la question</button>
</body>
</html>

viewers/script.js ​

js
const question = document.getElementById('question');
const choices = document.getElementById('choices');
const status = document.getElementById('status');
const refresh = document.getElementById('refresh');
let current = null;
let sending = false;

function render() {
    question.textContent = current.question;
    choices.replaceChildren();
    for (const choice of current.choices) {
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = choice;
        button.disabled = sending || current.closed;
        button.addEventListener('click', () => { void answer(choice); });
        choices.append(button);
    }
    if (current.closed) status.textContent = 'Vote terminé.';
}

function receive(state) {
    if (typeof state?.questionId !== 'string' || typeof state.question !== 'string' ||
        !Array.isArray(state.choices) || !state.choices.every(c => typeof c === 'string') ||
        typeof state.closed !== 'boolean') return;
    // Une réponse de synchronisation en retard ne doit pas rouvrir le même vote.
    if (current?.questionId === state.questionId && current.closed && !state.closed) return;
    current = state;
    render();
}

async function synchronize() {
    refresh.disabled = true;
    status.textContent = 'Chargement…';
    try {
        receive(await KappappsHost.emitWithReply('quiz-state'));
        if (current && !current.closed) status.textContent = 'Choisis ta réponse.';
    } catch (error) {
        status.textContent = `Question indisponible (${error.code}). Vérifie que le quiz est lancé.`;
    } finally {
        refresh.disabled = false;
    }
}

async function answer(choice) {
    if (!current || current.closed || sending) return;
    sending = true;
    render();
    try {
        const result = await KappappsHost.emitWithReply('answer', {questionId: current.questionId, choice});
        status.textContent = result.ok ? 'Réponse enregistrée.' : 'Réponse refusée : vote fermé ou invalide.';
    } catch (error) {
        status.textContent = `Confirmation indisponible (${error.code}). Le résultat du vote est inconnu.`;
        // Aucun renvoi automatique : l'appli a peut-être déjà traité la réponse.
    } finally {
        sending = false;
        render();
    }
}

KappappsHost.onSocketEvent(({event, data}) => {
    if (event === 'quiz-state') receive(data);
});
refresh.addEventListener('click', () => { void synchronize(); });
void synchronize(); // le helper attend l'hôte avec un délai borné, même avant onReady

Vérifier ​

Lance le quiz dans le DEV HUB et ouvre la preview viewers avec son shell. La question doit apparaître même si le plugin est ouvert après la diffusion initiale. Vote deux fois avec le même viewer : le dernier choix remplace le premier. Vérifie le refus d'un choix hors liste, d'une ancienne question et d'un vote après 30 secondes. Après fermeture, un instantané ancien ne doit pas réactiver les boutons.

Le bouton d'actualisation redemande uniquement la question ; il ne rejoue aucun vote. onReady indique que le pont est prêt, pas que l'appli est ouverte. Une appli absente ou une réponse perdue peut provoquer un timeout ; ce mini-quiz ne garantit ni la reprise après reconnexion ni la conservation des votes après redémarrage. Voir le contrat plugins.