8x32 PI : interface web, matrice verticale, compatibilité Raspbian 11
- Interface web (serveur_web.py, Flask, gabarits dans web/) : dépôt de GIF, PNG animés et séquences de PNG, aperçus animés, ordre de lecture, activer / désactiver, corbeille, « jouer maintenant » et « suivante ». Service systemd vitrine-web, activé par installer.sh. - Lecteur : réagit aux demandes de la page web (post-it dans /run/vitrine-pixels), écrit l'animation en cours, éteint la matrice proprement sur SIGTERM. - Matrice posée debout : réglage ROTATION (90 = image 8 × 32) dans matrice.py ; images couchées tournées automatiquement ; exemples, animations codées, aperçus et page web adaptés au format vertical. - Compatibilité Raspbian 11 Bullseye : partition /boot, Pillow 8.1, Flask 1.1. - Doc : nouveau guide INTERFACE_WEB.md ; README, GUIDE_KRITA, MODE_VITRINE, CODER_EN_PYTHON mis à jour (format 8 × 32, adresses Wi-Fi / câble du Pi). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{# Petit morceau de page, inclus dans chaque fiche : les informations d'une animation « a ». #}
|
||||
{% if a.erreur %}
|
||||
<span class="alerte">⚠ fichier illisible : {{ a.erreur }}</span>
|
||||
{% else %}
|
||||
<span class="infos">
|
||||
{% if a.infos.fixe %}image fixe{% else %}{{ a.infos.images }} images · {{ ("%.1f"|format(a.infos.duree)).replace(".", ",") }} s{% endif %}
|
||||
{% if a.infos.sequence %}· séquence PNG{% endif %}
|
||||
</span>
|
||||
{% if not a.infos.taille_ok %}
|
||||
<span class="alerte">⚠ {{ a.infos.taille[0] }} × {{ a.infos.taille[1] }} px au lieu de {{ largeur }} × {{ hauteur }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,208 @@
|
||||
<!doctype html>
|
||||
{#
|
||||
La page web de la vitrine. C'est un « gabarit » Jinja : Python (serveur_web.py)
|
||||
remplace les doubles accolades par des valeurs, et répète les blocs « for ».
|
||||
(Ce commentaire-ci, entre accolades-dièses, n'est pas envoyé au navigateur.)
|
||||
#}
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Vitrine pixels</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body class="{{ 'debout' if hauteur > largeur else 'couchee' }}">
|
||||
|
||||
<header>
|
||||
<h1><span class="logo" aria-hidden="true"></span>Vitrine pixels</h1>
|
||||
<div class="statut">
|
||||
<span class="pastille" aria-hidden="true"></span>
|
||||
En ce moment : <strong id="en-cours">{{ en_cours or "—" }}</strong>
|
||||
<form method="post" action="{{ url_for('action') }}">
|
||||
<input type="hidden" name="action" value="suivante">
|
||||
<button type="submit" class="bouton">Suivante ⏭</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
{# Les messages « flash » : résultat de la dernière action #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="messages" id="messages" role="status">
|
||||
{% for categorie, message in messages %}
|
||||
<p class="message {{ categorie }}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- =================== AJOUTER =================== -->
|
||||
<section id="ajouter">
|
||||
<h2>Ajouter des animations</h2>
|
||||
<div class="deux-colonnes">
|
||||
|
||||
<form class="carte" method="post" action="{{ url_for('ajouter') }}" enctype="multipart/form-data">
|
||||
<h3>Un ou plusieurs fichiers</h3>
|
||||
<p class="aide">GIF animé, PNG animé, WebP ou image fixe, exportés de Krita en
|
||||
<strong>{{ largeur }} × {{ hauteur }} pixels</strong>. Chaque fichier devient une animation.</p>
|
||||
<label class="depot">
|
||||
<input type="file" name="fichiers" multiple accept="{{ extensions }},image/*">
|
||||
<span>Choisir des fichiers…</span>
|
||||
</label>
|
||||
<button type="submit" class="bouton principal">Envoyer</button>
|
||||
</form>
|
||||
|
||||
<form class="carte" method="post" action="{{ url_for('ajouter_sequence') }}" enctype="multipart/form-data">
|
||||
<h3>Une séquence de PNG</h3>
|
||||
<p class="aide">Toutes les images d'un export Krita « séquence d'images »
|
||||
(<code>feu_0000.png</code>, <code>feu_0001.png</code>…), pour une seule animation.</p>
|
||||
<label class="depot">
|
||||
<input type="file" name="images" multiple accept=".png,image/png">
|
||||
<span>Choisir les PNG…</span>
|
||||
</label>
|
||||
<div class="champs">
|
||||
<label>Nom <input type="text" name="nom" placeholder="feu_de_camp" required></label>
|
||||
<label>Images/s <input type="number" name="ips" value="12" min="0.5" max="60" step="0.5"></label>
|
||||
</div>
|
||||
<button type="submit" class="bouton principal">Envoyer</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<p class="aide">Maximum {{ taille_max }} Mo par envoi. Les nouvelles animations
|
||||
se placent à la fin de la liste.</p>
|
||||
</section>
|
||||
|
||||
<!-- =================== JOUÉES =================== -->
|
||||
<section id="jouees">
|
||||
<h2>Jouées, dans cet ordre <span class="compte">{{ actives|length }}</span></h2>
|
||||
{% if not actives %}
|
||||
<p class="vide">Aucune animation active : la matrice affiche un petit point d'attente.</p>
|
||||
{% endif %}
|
||||
<ol class="liste">
|
||||
{% for a in actives %}
|
||||
<li class="fiche" id="a-{{ loop.index }}" data-relatif="{{ a.relatif }}">
|
||||
<span class="numero">{{ loop.index }}</span>
|
||||
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
|
||||
alt="Aperçu de {{ a.titre }}" loading="lazy">
|
||||
<div class="details">
|
||||
<strong>{{ a.titre }}</strong>
|
||||
{% include "infos.html" %}
|
||||
</div>
|
||||
<div class="boutons">
|
||||
{% set ancre = "#a-" ~ loop.index %}
|
||||
{% for quoi, signe, titre, actif in [
|
||||
("monter", "▲", "Monter", not loop.first),
|
||||
("descendre", "▼", "Descendre", not loop.last),
|
||||
("jouer", "▶", "Jouer maintenant", true),
|
||||
("desactiver", "⏸", "Ne plus jouer", true),
|
||||
("supprimer", "✕", "Supprimer", true)] %}
|
||||
<form method="post" action="{{ url_for('action') }}"
|
||||
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
|
||||
<input type="hidden" name="action" value="{{ quoi }}">
|
||||
<input type="hidden" name="nom" value="{{ a.nom }}">
|
||||
<input type="hidden" name="relatif" value="{{ a.relatif }}">
|
||||
<input type="hidden" name="ancre" value="{{ ancre }}">
|
||||
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
|
||||
aria-label="{{ titre }}" {% if not actif %}disabled{% endif %}>{{ signe }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- =================== DÉSACTIVÉES =================== -->
|
||||
<section id="desactivees">
|
||||
<h2>Pas jouées <span class="compte">{{ desactivees|length }}</span></h2>
|
||||
{% if not desactivees %}
|
||||
<p class="vide">Rien ici. Le bouton ⏸ d'une animation la range ici sans la supprimer.</p>
|
||||
{% endif %}
|
||||
<ul class="liste">
|
||||
{% for a in desactivees %}
|
||||
<li class="fiche eteinte" data-relatif="{{ a.relatif }}">
|
||||
<span class="numero">–</span>
|
||||
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
|
||||
alt="Aperçu de {{ a.titre }}" loading="lazy">
|
||||
<div class="details">
|
||||
<strong>{{ a.titre }}</strong>
|
||||
{% include "infos.html" %}
|
||||
</div>
|
||||
<div class="boutons">
|
||||
{% for quoi, signe, titre in [("activer", "+", "Remettre dans la liste"),
|
||||
("jouer", "▶", "Jouer maintenant (une fois)"),
|
||||
("supprimer", "✕", "Supprimer")] %}
|
||||
<form method="post" action="{{ url_for('action') }}"
|
||||
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
|
||||
<input type="hidden" name="action" value="{{ quoi }}">
|
||||
<input type="hidden" name="nom" value="{{ a.nom }}">
|
||||
<input type="hidden" name="relatif" value="{{ a.relatif }}">
|
||||
<input type="hidden" name="ancre" value="#desactivees">
|
||||
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
|
||||
aria-label="{{ titre }}">{{ signe }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<form method="post" action="{{ url_for('action') }}"
|
||||
data-confirmer="Effacer DÉFINITIVEMENT les {{ nb_corbeille }} élément(s) de la corbeille ?">
|
||||
Corbeille : {{ nb_corbeille }} élément(s)
|
||||
<input type="hidden" name="action" value="vider_corbeille">
|
||||
<button type="submit" class="bouton" {% if not nb_corbeille %}disabled{% endif %}>Vider</button>
|
||||
</form>
|
||||
<p>Adresses de cette page :
|
||||
{% for a in adresses %}<code>http://{{ a }}{{ port }}</code>{% if not loop.last %} · {% endif %}{% endfor %}
|
||||
<br>(celle qui commence comme l'adresse de votre appareil marche depuis votre réseau)</p>
|
||||
<p>Dossier : <code>{{ dossier }}</code></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Demander une confirmation avant les actions irréversibles.
|
||||
document.querySelectorAll("form[data-confirmer]").forEach(function (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
if (!confirm(form.dataset.confirmer)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// Afficher le nom des fichiers choisis dans les zones de dépôt.
|
||||
document.querySelectorAll(".depot input").forEach(function (champ) {
|
||||
champ.addEventListener("change", function () {
|
||||
var n = champ.files.length;
|
||||
champ.nextElementSibling.textContent =
|
||||
n === 0 ? "Choisir…" : n === 1 ? champ.files[0].name : n + " fichiers choisis";
|
||||
});
|
||||
});
|
||||
|
||||
// Les messages disparaissent tout seuls après 6 secondes (sauf les erreurs).
|
||||
var messages = document.getElementById("messages");
|
||||
if (messages && !messages.querySelector(".erreur")) {
|
||||
setTimeout(function () { messages.remove(); }, 6000);
|
||||
}
|
||||
|
||||
// Toutes les 2 secondes, demander au serveur ce que joue la matrice,
|
||||
// et mettre en valeur la fiche correspondante.
|
||||
function rafraichir() {
|
||||
fetch("{{ url_for('etat') }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (etat) {
|
||||
document.getElementById("en-cours").textContent = etat.en_cours || "—";
|
||||
document.querySelectorAll(".fiche").forEach(function (f) {
|
||||
f.classList.toggle("joue", f.dataset.relatif === etat.en_cours);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
rafraichir();
|
||||
setInterval(rafraichir, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,172 @@
|
||||
/* =============================================================================
|
||||
Feuille de style de l'interface web « Vitrine pixels »
|
||||
Les couleurs sont rangées dans des « variables » (--fond, --texte…) :
|
||||
on les redéfinit pour le mode sombre, sans toucher au reste.
|
||||
============================================================================= */
|
||||
|
||||
:root {
|
||||
--fond: #f4f1ea;
|
||||
--carte: #ffffff;
|
||||
--texte: #1d1b18;
|
||||
--discret: #6b665d;
|
||||
--bord: #ddd6c8;
|
||||
--accent: #e8462b; /* le rouge-orangé d'une LED allumée */
|
||||
--accent-texte: #ffffff;
|
||||
--vert: #1f8a4c;
|
||||
--alerte: #b3261e;
|
||||
--ecran: #101010; /* le noir de la matrice */
|
||||
--rayon: 12px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fond: #141312;
|
||||
--carte: #1f1d1b;
|
||||
--texte: #f1ede6;
|
||||
--discret: #a39d92;
|
||||
--bord: #36322d;
|
||||
--accent: #ff5a3c;
|
||||
--vert: #4cc283;
|
||||
--alerte: #ff8a80;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--fond);
|
||||
color: var(--texte);
|
||||
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
code { font-size: 0.9em; }
|
||||
|
||||
/* ---------- en-tête ---------- */
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
|
||||
gap: 8px 24px;
|
||||
padding: 12px 20px;
|
||||
background: var(--ecran);
|
||||
color: #f1ede6;
|
||||
}
|
||||
h1 { margin: 0; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; }
|
||||
.logo {
|
||||
width: 32px; height: 8px;
|
||||
background:
|
||||
radial-gradient(circle, var(--accent) 1.6px, transparent 2px) 0 0 / 4px 4px;
|
||||
}
|
||||
.statut { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.statut form { margin: 0; }
|
||||
.pastille {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #4cc283; box-shadow: 0 0 8px #4cc283;
|
||||
}
|
||||
|
||||
main { max-width: 980px; margin: 0 auto; padding: 8px 16px 32px; }
|
||||
section { margin-top: 28px; }
|
||||
h2 { font-size: 1.15rem; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; }
|
||||
h3 { margin: 0 0 6px; font-size: 1rem; }
|
||||
.compte {
|
||||
font-size: 0.8rem; font-weight: 600;
|
||||
background: var(--bord); border-radius: 99px; padding: 1px 9px;
|
||||
}
|
||||
.aide, .vide, footer { color: var(--discret); font-size: 0.9rem; }
|
||||
|
||||
/* ---------- messages ---------- */
|
||||
/* En bas de l'écran, toujours visibles même si la page a défilé. */
|
||||
.messages {
|
||||
position: fixed; z-index: 20; left: 50%; bottom: 16px; transform: translateX(-50%);
|
||||
width: min(560px, calc(100% - 32px));
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.message {
|
||||
margin: 0; padding: 10px 14px;
|
||||
border-radius: var(--rayon); border-left: 5px solid var(--vert);
|
||||
background: var(--carte); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.message.erreur { border-left-color: var(--alerte); }
|
||||
|
||||
/* ---------- formulaires d'ajout ---------- */
|
||||
.deux-colonnes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
.carte {
|
||||
background: var(--carte); border: 1px solid var(--bord);
|
||||
border-radius: var(--rayon); padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.carte .aide { margin: 0; }
|
||||
.depot {
|
||||
display: block; position: relative; cursor: pointer;
|
||||
border: 2px dashed var(--bord); border-radius: var(--rayon);
|
||||
padding: 18px; text-align: center; color: var(--discret);
|
||||
}
|
||||
.depot:hover { border-color: var(--accent); color: var(--texte); }
|
||||
.depot input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
|
||||
.champs { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.champs label { display: flex; flex-direction: column; font-size: 0.85rem; color: var(--discret); flex: 1; }
|
||||
.champs input {
|
||||
margin-top: 4px; padding: 8px; font: inherit; color: var(--texte);
|
||||
background: var(--fond); border: 1px solid var(--bord); border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ---------- boutons ---------- */
|
||||
.bouton {
|
||||
font: inherit; cursor: pointer;
|
||||
padding: 7px 14px; border-radius: 8px;
|
||||
border: 1px solid var(--bord); background: var(--carte); color: var(--texte);
|
||||
}
|
||||
header .bouton { background: #2a2826; border-color: #3d3935; color: #f1ede6; }
|
||||
.bouton:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.bouton:disabled { opacity: 0.35; cursor: default; }
|
||||
.bouton.principal { background: var(--accent); border-color: var(--accent); color: var(--accent-texte); font-weight: 600; }
|
||||
.bouton.icone { width: 38px; height: 38px; padding: 0; font-size: 1rem; }
|
||||
.bouton.jouer { color: var(--vert); }
|
||||
.bouton.supprimer { color: var(--alerte); }
|
||||
|
||||
/* ---------- listes d'animations ---------- */
|
||||
.liste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.fiche {
|
||||
display: grid; align-items: center; gap: 8px 14px;
|
||||
grid-template-columns: 28px 256px 1fr auto;
|
||||
background: var(--carte); border: 1px solid var(--bord);
|
||||
border-radius: var(--rayon); padding: 10px 12px;
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
.fiche.joue { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
.fiche.eteinte .apercu { opacity: 0.55; }
|
||||
.numero { font-weight: 700; color: var(--discret); text-align: center; }
|
||||
.apercu {
|
||||
width: 256px; height: 64px; display: block;
|
||||
background: var(--ecran); border-radius: 4px;
|
||||
image-rendering: pixelated; /* garder les pixels nets, pas de flou */
|
||||
}
|
||||
.details { display: flex; flex-direction: column; min-width: 0; }
|
||||
.details strong { overflow-wrap: anywhere; }
|
||||
.infos { font-size: 0.85rem; color: var(--discret); }
|
||||
.alerte { font-size: 0.85rem; color: var(--alerte); }
|
||||
.boutons { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.boutons form { margin: 0; }
|
||||
|
||||
/* Matrice DEBOUT (8 × 32) : aperçus verticaux, plus étroits. */
|
||||
.debout .fiche { grid-template-columns: 28px 48px 1fr auto; }
|
||||
.debout .apercu { width: 48px; height: 192px; }
|
||||
|
||||
/* Sur téléphone : les boutons passent sous l'aperçu. */
|
||||
@media (max-width: 640px) {
|
||||
.couchee .fiche { grid-template-columns: 24px 1fr; }
|
||||
.couchee .apercu { width: 100%; height: auto; aspect-ratio: 4 / 1; grid-column: 2; }
|
||||
.couchee .details, .couchee .boutons { grid-column: 1 / -1; }
|
||||
.debout .fiche { grid-template-columns: 20px 48px 1fr; }
|
||||
.debout .boutons { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 980px; margin: 0 auto; padding: 16px;
|
||||
border-top: 1px solid var(--bord);
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px;
|
||||
}
|
||||
footer form { display: flex; align-items: center; gap: 10px; margin: 0; }
|
||||
footer p { margin: 0; }
|
||||
Reference in New Issue
Block a user