Files
2026-02-26 22:03:45 +01:00

1127 lines
30 KiB
Arduino
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <SD.h>
#include "animation.h"
#include "driver/temperature_sensor.h"
// #define PHILLIPS_HUE_URL "https://192.168.0.20/clip/v2/resource/light/5557eceb-fd62-4859-b587-28f67e948969"
#define PHILLIPS_HUE_URL "https://192.168.0.20/clip/v2/resource/light/"
#define PHILLIPS_HUE_WZ_MITTE "5557eceb-fd62-4859-b587-28f67e948969"
#define PHILLIPS_HUE_APPLICATION_KEY_NAME "hue-application-key"
#define PHILLIPS_HUE_APPLICATION_KEY_VALUE "Xs4bbYSXvlPlNR7nKmpLpsLnLCbz42C4SrvO93Fz"
#define PIN_NEO_PIXEL 2 // The ESP32 pin GPIO16 connected to NeoPixel
#define NUM_PIXELS 400 // The number of LEDs (pixels) on NeoPixel LED strip
#define COLLUMMS_PANEL 20
#define ROWS_PANEL 20
#define TARGET_UPDATE_RATE 1.0/30.0*1000.0 //Updaterate in ms
#define ANIMATION_CYCLE_SPEED_MS 2000 //Switch animation every 20s
#define FRAME_SIZE (COLLUMMS_PANEL * ROWS_PANEL * 3) // 1200 bytes per frame
#define SD_CS 7
#define SD_MOSI 6
#define SD_MISO 5
#define SD_SCK 4
#define BUTTON 3
#define WebServerOn
enum EntryType {
COUNT_FOLDERS,
COUNT_FILES
};
enum displayType {
DISPLAY_ANIMATION,
DISPLAY_VIDEO,
DISPLAY_PICTURE
};
Adafruit_NeoPixel NeoPixel(NUM_PIXELS, PIN_NEO_PIXEL, NEO_GRB + NEO_KHZ800);
String ServerResponseBody;
String ServerResponseBody2;
HTTPClient http;
StaticJsonDocument<20000> doc;
SPIClass spiSD(FSPI); // Use FSPI on ESP32
File myFile;
temperature_sensor_handle_t temp_sensor = NULL;
#ifdef WebServerOn
WebServer server(80);
#endif
// const char* ssid = "FRITZ!Box 6490 Cable";
// const char* password = "29644657346255273822";
int serverFrameOverride = 0;
const char* ssid = "Ununu-Schanze";
const char* password = "68838192089895491452";
void setup() {
Serial.begin(115200);
NeoPixel.begin(); // initialize NeoPixel strip object (REQUIRED)
WiFi.begin(ssid, password);
connectWifi(); //Connect to Wifi and show a progress bar on the matrix. Also returns IP-Address over Serial
startSDCard(); //Tries to look for SD-Card and start SPI communication with it
#ifdef WebServerOn //Activate WebServer and bind actions for requests (GET POST)
server.on("/", HTTP_GET, handleRoot);
server.on("/set", HTTP_POST, handleSet);
server.on("/normal", HTTP_POST, handleNormal);
server.begin();
#endif
NeoPixel.fill(NeoPixel.Color(0,0,0),0,99);
NeoPixel.setBrightness(50);
NeoPixel.show();
pinMode(BUTTON, INPUT);
// while(digitalRead(BUTTON)){
// displayAnimationFrame(millis(), 1, 0);
// }
// while(true){
// NeoPixel.fill(PhillipsHueColor(PHILLIPS_HUE_WZ_MITTE));
// NeoPixel.show();
// delay(200);
// }
// while(true){
// showManuRoom();
// }
}
//
void loop() {
// float bri = PhillipsHueBrightness(PHILLIPS_HUE_URL);
// int on = PhillipsHueOn(PHILLIPS_HUE_URL);
static int previousMillis = millis();
static displayType Type = DISPLAY_ANIMATION;
server.handleClient();;
if(!serverFrameOverride){
switch(Type){
case DISPLAY_ANIMATION:
if(displayAnimationFrame(millis(), 1, 0) == 3){Type = DISPLAY_PICTURE;}
break;
case DISPLAY_VIDEO:
if(updateVideoPlayback() == 3){Type = DISPLAY_ANIMATION;}
break;
case DISPLAY_PICTURE:
if(updatePicturePlayback() == 3){Type = DISPLAY_VIDEO;}
break;
default:
break;
}
}else{
parseJsonAndSetPixel();
}
}
int updatePicturePlayback() {
static unsigned long previousMillis = -20000;
static int maxFolders = -1;
static int folderIndex = 0;
static int frameIndex = 0;
static bool playing = false;
unsigned long currentMillis = millis();
// Respect update rate without blocking
if (currentMillis - previousMillis < TARGET_UPDATE_RATE*30*5) { //every 20sec
return 0;
}
previousMillis = currentMillis;
// Initialize folder count once
if (maxFolders == -1) {
maxFolders = countTopLevelEntries(SD, "/pictures", COUNT_FOLDERS);
folderIndex = 0;
frameIndex = 0;
playing = true;
}
if (folderIndex >= maxFolders) {
// Restart from first folder (or stop if preferred)
folderIndex = 0;
return 3;
}
String pathToRead = getEntryByIndex(SD, "/pictures", folderIndex, COUNT_FOLDERS);
// Try to read ONE frame only (non-blocking)
bool frameExists = readEntryByPathSetPixelFrameStream(
SD,
pathToRead + "/frame.bin",
frameIndex
);
if (frameExists) {
NeoPixel.show();
frameIndex++;
} else {
// Move to next folder when frames are done
frameIndex = 0;
folderIndex++;
}
// Button handling (non-blocking edge detect)
static bool lastButtonState = HIGH;
static int lastButtonStateTime = millis();
bool currentButtonState = digitalRead(BUTTON);
if (lastButtonState == HIGH && currentButtonState == LOW) {
// Button pressed
delay(20);
folderIndex++;
frameIndex = 0;
}
lastButtonState = currentButtonState;
lastButtonStateTime = millis();
}
int updateVideoPlayback() {
static unsigned long previousMillis = 0;
static int maxFolders = -1;
static int folderIndex = 0;
static int frameIndex = 0;
static bool playing = false;
unsigned long currentMillis = millis();
// Respect update rate without blocking
if (currentMillis - previousMillis < TARGET_UPDATE_RATE) {
return 0;
}
previousMillis = currentMillis;
// Initialize folder count once
if (maxFolders == -1) {
maxFolders = countTopLevelEntries(SD, "/videos", COUNT_FOLDERS);
folderIndex = 0;
frameIndex = 0;
playing = true;
}
if (folderIndex >= maxFolders) {
// Restart from first folder (or stop if preferred)
folderIndex = 0;
return 3;
}
String pathToRead = getEntryByIndex(SD, "/videos", folderIndex, COUNT_FOLDERS);
// Try to read ONE frame only (non-blocking)
bool frameExists = readEntryByPathSetPixelFrameStream(
SD,
pathToRead + "/frame.bin",
frameIndex
);
if (frameExists) {
NeoPixel.show();
frameIndex++;
} else {
// Move to next folder when frames are done
frameIndex = 0;
folderIndex++;
}
// Button handling (non-blocking edge detect)
static bool lastButtonState = HIGH;
static int lastButtonStateTime = millis();
bool currentButtonState = digitalRead(BUTTON);
if (lastButtonState == HIGH && currentButtonState == LOW) {
// Button pressed
delay(20);
folderIndex++;
frameIndex = 0;
}
lastButtonState = currentButtonState;
lastButtonStateTime = millis();
}
int displayAnimationFrame(int timeMs, int CycleAnimationOn, int AnimationIndex = 0){
uint32_t color;
static int animation = AnimationIndex;
static int prevtimeanimation = timeMs;
if(!CycleAnimationOn){
animation = AnimationIndex;
}
for (int y = 0; y < ROWS_PANEL; y++) {
for (int x = 0; x < COLLUMMS_PANEL; x++) {
switch(animation){
case 0:
animation++;
break;
case 1:
color = bubbles(x, y, timeMs);
break;
case 2:
color = galaxy(x, y, timeMs);
break;
case 3:
animation++;
break;
case 4:
color = flyingWireCubeHard(x, y, timeMs);
break;
case 5:
color = pacman(x, y, timeMs);
break;
case 6:
color = coolEffect(x, y, timeMs);
break;
case 7:
color = creeper(x, y, timeMs);
break;
case 8:
color = flappyBirdPixel(x, y, timeMs);
break;
case 9:
color = xwingDeathStarPixel(x, y, timeMs);
break;
case 10:
color = nyanCatPixel(x, y, timeMs);
break;
case 11:
color = bonfireMoonPixel(x, y, timeMs);
break;
case 12:
color = jumpingJackPixel(x, y, timeMs);
break;
case 13:
color = spinningDavidStarPixel(x, y, timeMs);
break;
case 14:
color = starrySky(x, y, timeMs);
break;
case 15:
color = purpleRain(x, y, timeMs);
break;
case 16:
color = amongUsPixel(x, y, timeMs);
break;
case 17:
color = minecraftPumpkin(x, y, timeMs);
break;
default:
animation = 0;
return 3;
break;
}
if(timeMs - prevtimeanimation >= ANIMATION_CYCLE_SPEED_MS){
animation++;
prevtimeanimation = timeMs;
}
NeoPixel.setPixelColor(getPixelByCoordinate(x,y), color);
}
}
NeoPixel.show();
return 0;
}
void showManuRoom(void){
for(int y = 0; y < ROWS_PANEL; y++){
for(int x = 0; x < COLLUMMS_PANEL; x++){
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), NeoPixel.Color(imageManuBude[y][x] ? 255: 0, imageManuBude[y][x] ? 255: 0, imageManuBude[y][x] ? 255: 0));
}
}
NeoPixel.setPixelColor(getPixelByCoordinate(13,16), PhillipsHueColor("5557eceb-fd62-4859-b587-28f67e948969"));
NeoPixel.setPixelColor(getPixelByCoordinate(13,17), PhillipsHueColor("5ccff572-baaa-4fa0-8998-d43e595e28c5"));
NeoPixel.setPixelColor(getPixelByCoordinate(13,18), PhillipsHueColor("29cc7114-9f1f-44ba-baab-00718cd4d1f3"));
NeoPixel.setPixelColor(getPixelByCoordinate(0,17), PhillipsHueColor("ccc6bced-f859-4a3d-8af2-7885f79d166c"));
NeoPixel.setPixelColor(getPixelByCoordinate(2,17), PhillipsHueColor("76b2d794-657a-43ed-8f0e-f15c56e396e1"));
NeoPixel.setPixelColor(getPixelByCoordinate(4,17), PhillipsHueColor("ccc6bced-f859-4a3d-8af2-7885f79d166c"));
NeoPixel.setPixelColor(getPixelByCoordinate(11,7), PhillipsHueColor("e3c9d639-72df-415f-a579-2311dedbd2e7"));
NeoPixel.setPixelColor(getPixelByCoordinate(11,8), PhillipsHueColor("acec0a2e-0755-4e79-8c77-a4e972d11149"));
NeoPixel.setPixelColor(getPixelByCoordinate(10,4), PhillipsHueColorOnlyBrightness("563584d7-7252-45a0-9109-332f5714781d"));
NeoPixel.setPixelColor(getPixelByCoordinate(10,3), PhillipsHueColorOnlyBrightness("72f09c6e-0a5b-4836-8bda-7249248c1e01"));
NeoPixel.setPixelColor(getPixelByCoordinate(0,11), PhillipsHueColor("5a13afda-216a-4b41-bec2-96beaee94012"));
NeoPixel.setPixelColor(getPixelByCoordinate(0,12), PhillipsHueColor("ee8b00b0-4969-4274-8e5e-df291b06c9f7"));
NeoPixel.setPixelColor(getPixelByCoordinate(4,11), PhillipsHueColorOnlyBrightness("d95fa043-d067-48f5-8b3a-59ea650fe5c0"));
NeoPixel.setPixelColor(getPixelByCoordinate(4,12), PhillipsHueColor("ee63ef6a-72dd-4a8e-a2d3-7f446a241699"));
NeoPixel.setPixelColor(getPixelByCoordinate(4,13), PhillipsHueColorOnlyBrightness("eb7f0210-b132-46b1-a061-853efcc42cfb"));
NeoPixel.show();
}
int readEntryByPathSetPixelFrameStream(fs::FS &fs, const String &filePath, int frameIndex) {
static uint8_t frameBuffer[FRAME_SIZE]; // preallocated buffer
File file = fs.open(filePath);
if(!file) {
Serial.println("Could not open animation file on SD!");
return 0;
}
// Seek to the frame start
if(!file.seek(frameIndex * FRAME_SIZE)) {
Serial.println("Seek failed!");
file.close();
return 0;
}
// Read the frame into RAM
size_t bytesRead = file.read(frameBuffer, FRAME_SIZE);
file.close();
if(bytesRead != FRAME_SIZE) {
Serial.println("Frame read incomplete!");
return 0;
}
// Set pixels
int idx = 0;
for(int y = 0; y < ROWS_PANEL; y++) {
for(int x = 0; x < COLLUMMS_PANEL; x++) {
uint8_t r = frameBuffer[idx++];
uint8_t g = frameBuffer[idx++];
uint8_t b = frameBuffer[idx++];
uint32_t color = NeoPixel.Color(r, g, b);
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), color);
}
}
return 1;
}
int readEntryByPathSetPixelFrame(fs::FS &fs, String path){
int x = 0;
int y = 0;
uint8_t frameBuffer[COLLUMMS_PANEL*ROWS_PANEL*3];
File file = fs.open(path);
if(!file){
Serial.println("Could not open file on SD!");
return 0;
}
size_t bytesRead = file.read(frameBuffer, COLLUMMS_PANEL*ROWS_PANEL*3);
file.close();
if(bytesRead != COLLUMMS_PANEL*ROWS_PANEL*3){
Serial.println("Frame size mismatch!");
return 0;
}
int idx = 0;
for(int y = 0; y < ROWS_PANEL; y++){
for(int x = 0; x < COLLUMMS_PANEL; x++){
uint8_t r = frameBuffer[idx++];
uint8_t g = frameBuffer[idx++];
uint8_t b = frameBuffer[idx++];
uint32_t color = NeoPixel.Color(r, g, b);
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), color);
}
}
Serial.println("Read File!");
return 1;
}
int readEntryByPath(fs::FS &fs,String &data, String path){
File file = SD.open(path);
if(file){
Serial.println("Reading File...");
data = "";
while(file.available()){
int temp = file.read();
Serial.write(temp);
ServerResponseBody2 += (char)temp;
}
file.close();
Serial.println("Read File!");
return 1;
}else{
Serial.println("Could not read data from SD-Card");
return 0;
}
}
String getEntryByIndex(fs::FS &fs, String path, int index, EntryType type) {
File root = fs.open(path);
if (!root || !root.isDirectory()) return "";
int currentIndex = 0;
File file = root.openNextFile();
while (file) {
bool match =
(type == COUNT_FOLDERS && file.isDirectory()) ||
(type == COUNT_FILES && !file.isDirectory());
if (match) {
if (currentIndex == index) {
return String(path) + "/" + file.name();
}
currentIndex++;
}
file = root.openNextFile();
}
return ""; // index out of range
}
int countTopLevelEntries(fs::FS &fs, String path, EntryType type) {
File root = fs.open(path);
if (!root || !root.isDirectory()) return 0;
int count = 0;
File file = root.openNextFile();
while (file) {
if (type == COUNT_FOLDERS && file.isDirectory()) {
count++;
}
else if (type == COUNT_FILES && !file.isDirectory()) {
count++;
}
file = root.openNextFile();
}
return count;
}
void startSDCard(){
spiSD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
for(int i = 0; i < 20; i++){
if (!SD.begin(SD_CS, spiSD, 80000000)) {
Serial.println("❌ SD Card Mount Failed " + String(i));
if (!SD.begin(SD_CS, spiSD, 80000000) && i == 19) {
return;
}
}
}
Serial.println("✅ SD Card initialized.");
}
void connectWifi(){
int wifiSymbol[7][9] = {{0,0,1,1,1,1,1,0,0},
{0,1,0,0,0,0,0,1,0},
{1,0,0,0,0,0,0,0,1},
{0,0,0,1,1,1,0,0,0},
{0,0,1,0,0,0,1,0,0},
{0,0,0,0,0,0,0,0,0},
{0,0,0,0,1,0,0,0,0}};
while (WiFi.status() != WL_CONNECTED) { //Connect to WIFI
static int cycles = 0;
delay(50);
Serial.print(".");
NeoPixel.fill(NeoPixel.Color(0,0,0));
for(int x = 0; x < 9; x++){
for(int y = 0; y < 7; y++){
if(wifiSymbol[y][x] && (cycles%10 > 7-y))
NeoPixel.setPixelColor(getPixelByCoordinate(x+5, y+6), NeoPixel.Color(255,100,0));
}
}
NeoPixel.show();
cycles++;
}
Serial.println("\nConnected!");
Serial.println(WiFi.localIP());
//Success Animation
for(int j = 0; j < 3; j++){ //3 Times
for(int x = 0; x < 9; x++){
for(int y = 0; y < 7; y++){
if(wifiSymbol[y][x])
NeoPixel.setPixelColor(getPixelByCoordinate(x+5, y+6), NeoPixel.Color(0,255,0));
}
}
NeoPixel.show();
delay(300);
NeoPixel.fill(NeoPixel.Color(0,0,0));
NeoPixel.show();
delay(300);
}
}
float PhillipsHueBrightness(String ServiceLightID){ //return float in % (0-100) of current lightbrightness input: id of light to read
http.begin(PHILLIPS_HUE_URL);
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
Serial.println("Sending GET");
int ResponseCodeHttp = http.GET();
ServerResponseBody = http.getString();
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
deserializeJson(doc, ServerResponseBody);
return (float)doc["data"][0]["dimming"]["brightness"];
}
int PhillipsHueOn(String ServiceLightID){ //Returns 1 when light is switched on input: id of light to read
http.begin(PHILLIPS_HUE_URL);
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
Serial.println("Sending GET");
int ResponseCodeHttp = http.GET();
ServerResponseBody = http.getString();
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
deserializeJson(doc, ServerResponseBody);
return doc["data"][0]["on"]["on"] == true ? 1 : 0;
}
uint32_t PhillipsHueColor(String ServiceLightID){
http.begin(PHILLIPS_HUE_URL+ServiceLightID);
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
Serial.println("Sending GET");
int ResponseCodeHttp = http.GET();
ServerResponseBody = http.getString();
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
deserializeJson(doc, ServerResponseBody);
float x = (float)doc["data"][0]["color"]["xy"]["x"];
float y = (float)doc["data"][0]["color"]["xy"]["y"];
float brightness = (float)doc["data"][0]["dimming"]["brightness"];
doc["data"][0]["on"]["on"] == false ? brightness = 0 : brightness = brightness;
return ConvertHueToRGB888Neo(x, y, brightness);
}
uint32_t PhillipsHueColorOnlyBrightness(String ServiceLightID){
http.begin(PHILLIPS_HUE_URL+ServiceLightID);
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
Serial.println("Sending GET");
int ResponseCodeHttp = http.GET();
ServerResponseBody = http.getString();
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
deserializeJson(doc, ServerResponseBody);
float brightness = (float)doc["data"][0]["dimming"]["brightness"];
doc["data"][0]["on"]["on"] == false ? brightness = 0 : brightness = brightness;
int bri = (int)(brightness * 255.0 / 100.0);
return NeoPixel.Color(bri,(int)(bri*0.8),(int)(bri*0.7));
}
uint32_t ConvertHueToRGB888Neo(float x, float y, float brightness){
float bri = brightness/100.0;
float Y = bri; // Luminance
float X = (Y / y) * x;
float Z = (Y / y) * (1.0 - x - y);
float r = 1.656492 * X - 0.354851 * Y - 0.255038 * Z;
float g = -0.707196 * X + 1.655397 * Y + 0.036152 * Z;
float b = 0.051713 * X - 0.121364 * Y + 1.011530 * Z;
r = gammaCorrect(r);
g = gammaCorrect(g);
b = gammaCorrect(b);
r = constrain(r, 0.0, 1.0);
g = constrain(g, 0.0, 1.0);
b = constrain(b, 0.0, 1.0);
uint8_t R = (uint8_t)(r * 255);
uint8_t G = (uint8_t)(g * 255);
uint8_t B = (uint8_t)(b * 255);
return NeoPixel.Color(R,G,B);
}
float gammaCorrect(float c) {
if (c <= 0.0031308)
return 12.92 * c;
else
return (1.0 + 0.055) * pow(c, 1.0 / 2.4) - 0.055;
}
//Used for WebApp
void parseJsonAndSetPixel(){
int prevmil = millis();
deserializeJson(doc, ServerResponseBody2);
Serial.println(millis()-prevmil);
for(int frame = 0; frame < doc.size(); frame++){
for(int y = 0; y < 20/*doc[0].size()*/; y++){
for(int x = 0; x < 20/*doc[0][y].size()*/; x++){
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), NeoPixel.Color(doc[frame][y][x][0], doc[frame][y][x][1], doc[frame][y][x][2]));
Serial.print("Pixel ");
Serial.print(getPixelByCoordinate(x, y));
Serial.print(" Red ");
Serial.println((int)doc[frame][y][x][0]);
}
}
NeoPixel.show();
delay(1);
}
}
int getPixelByCoordinate(int x, int y){ //Starts from top left 0,0
int pixel = x+y*COLLUMMS_PANEL;
if(x < COLLUMMS_PANEL/2 && y < ROWS_PANEL/2){ //TOPLEFT
return pixel = x+y*COLLUMMS_PANEL/2;
}
else if (x >= COLLUMMS_PANEL/2 && y < ROWS_PANEL/2){ //TOPRIGHT
return pixel = (x-10)+y*COLLUMMS_PANEL/2+100;
}
else if(x < COLLUMMS_PANEL/2 && y >= ROWS_PANEL/2){ //BOTLEFT
return pixel = x+(y-10)*COLLUMMS_PANEL/2 + 300;
}
else{ //BOTRIGHT
return pixel = (x-10)+(y-10)*COLLUMMS_PANEL/2 + 200;
}
}
#ifdef WebServerOn
void handleSet() {
if (server.hasArg("plain") == false) {
server.send(400, "text/plain", "No ServerResponseBody received");
return;
}
serverFrameOverride = 1;
ServerResponseBody2 = server.arg("plain"); // RAW POST ServerResponseBody
server.send(200, "application/json", "Server recieved data!");
Serial.println(ServerResponseBody2);
parseJsonAndSetPixel();
}
void handleNormal(){
serverFrameOverride = 0;
server.send(200, "application/json", "Server recieved data!");
}
#endif
#ifdef WebServerOn
void handleRoot() {
server.send(200, "text/html", R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Multi-Frame RGB Pixel Editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
body {
font-family: sans-serif;
margin: 8px;
text-align: center;
}
h2 {
margin-bottom: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-bottom: 10px;
}
.toolbar button,
.toolbar input,
.toolbar label {
font-size: 16px;
}
.toolbar button {
padding: 10px 14px;
min-width: 44px;
}
input[type="number"] {
width: 80px;
padding: 6px;
}
input[type="color"] {
width: 44px;
height: 44px;
padding: 0;
border: 2px solid black;
}
/* MAIN CANVAS — BIG */
#canvas {
border: 1px solid black;
image-rendering: pixelated;
touch-action: none;
width: min(95vw, 520px);
height: min(95vw, 520px);
}
/* VIEWPORT — SMALL */
#viewport {
border: 1px solid black;
image-rendering: pixelated;
width: 120px;
height: 120px;
}
.viewports {
display: flex;
justify-content: center;
align-items: center;
gap: 14px;
margin-top: 10px;
}
.viewport-container {
display: flex;
flex-direction: column;
align-items: center;
font-size: 13px;
opacity: 0.85;
}
.status {
margin-top: 8px;
font-size: 14px;
}
#eraseIndicator {
position: fixed;
top: 300px;
right: 16px;
font-size: 36px;
pointer-events: none;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.15s ease, transform 0.15s ease;
}
</style>
</head>
<body>
<h2>Multi-Frame RGB Pixel Editor</h2>
<div class="toolbar">
<label>
Size
<input type="number" id="sizeInput" value="20" min="1" max="128">
</label>
<button onclick="applySize()">Apply</button>
<input type="color" id="colorPicker" value="#ff0000">
<button onclick="prevFrame()"></button>
<span id="frameLabel">Frame 1 / 1</span>
<button onclick="nextFrame()"></button>
<button onclick="addFrame()"></button>
<button onclick="deleteFrame()">🗑</button>
<label>
<input type="checkbox" id="copyPrev" checked>
Copy
</label>
<button onclick="exportFrames()">Export</button>
<button onclick="activateNormalCycle()">Activate normal cycle</button>
<label>
Import
<input type="file" id="pngInput" accept="image/png">
</label>
</div>
<!-- BIG DRAWING AREA -->
<canvas id="canvas"></canvas>
<!-- SMALL PREVIOUS FRAME VIEW -->
<div class="viewports">
<div class="viewport-container">
<div>Previous</div>
<canvas id="viewport"></canvas>
</div>
</div>
<div class="status" id="pixelStatus">Pixel: —</div>
<div id="eraseIndicator">🧽</div>
<script>
const CANVAS_PX = 512;
const VIEWPORT_PX = 128;
const ERASE_DELAY = 500; // ms
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const viewport = document.getElementById("viewport");
const vctx = viewport.getContext("2d");
const colorPicker = document.getElementById("colorPicker");
const sizeInput = document.getElementById("sizeInput");
const frameLabel = document.getElementById("frameLabel");
const pixelStatus = document.getElementById("pixelStatus");
const copyPrevCheckbox = document.getElementById("copyPrev");
const test = document.getElementById("test");
const eraseIndicator = document.getElementById("eraseIndicator");
let logicalSize = 20;
let scale, vscale;
let isDrawing = false;
let frames = [];
let currentFrame = 0;
/* gesture state */
let touchStartTime = 0;
let hasMoved = false;
let mode = null; // "paint" | "erase" | null
let isTouchActive = false;
canvas.addEventListener("contextmenu", e => e.preventDefault());
function createEmptyFrame(size) {
return Array.from({ length: size }, () =>
Array.from({ length: size }, () => [0, 0, 0])
);
}
function cloneFrame(frame) {
return frame.map(row => row.map(p => [...p]));
}
function applySize() {
logicalSize = parseInt(sizeInput.value);
scale = CANVAS_PX / logicalSize;
vscale = VIEWPORT_PX / logicalSize;
canvas.width = CANVAS_PX;
canvas.height = CANVAS_PX;
viewport.width = VIEWPORT_PX;
viewport.height = VIEWPORT_PX;
frames = [createEmptyFrame(logicalSize)];
currentFrame = 0;
updateFrameLabel();
draw();
}
function drawFrame(ctxToDraw, pixels, pixelSize) {
ctxToDraw.clearRect(0, 0, pixelSize * logicalSize, pixelSize * logicalSize);
ctxToDraw.strokeStyle = "#aaa";
for (let y = 0; y < logicalSize; y++) {
for (let x = 0; x < logicalSize; x++) {
const [r, g, b] = pixels[y][x];
ctxToDraw.fillStyle = `rgb(${r},${g},${b})`;
ctxToDraw.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctxToDraw.strokeRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
function draw() {
drawFrame(ctx, frames[currentFrame], scale);
if (currentFrame > 0) {
drawFrame(vctx, frames[currentFrame - 1], vscale);
} else {
vctx.clearRect(0, 0, VIEWPORT_PX, VIEWPORT_PX);
}
}
function getPointerPos(e) {
const rect = canvas.getBoundingClientRect();
const p = e.touches ? e.touches[0] : e;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.floor((p.clientX - rect.left) * scaleX / scale),
y: Math.floor((p.clientY - rect.top) * scaleY / scale)
};
}
function paint(e) {
e.preventDefault();
const { x, y } = getPointerPos(e);
if (x < 0 || y < 0 || x >= logicalSize || y >= logicalSize) return;
if (isErasing || e.buttons === 2) {
frames[currentFrame][y][x] = [0, 0, 0];
} else {
const hex = colorPicker.value;
frames[currentFrame][y][x] = [
parseInt(hex.slice(1,3),16),
parseInt(hex.slice(3,5),16),
parseInt(hex.slice(5,7),16)
];
}
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
draw();
}
/* Mouse */
canvas.addEventListener("mousedown", e => {
isDrawing = true;
isErasing = e.button === 2;
paint(e);
});
canvas.addEventListener("mousemove", e => {
isDrawing && paint(e);
const { x, y } = getPointerPos(e);
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
});
window.addEventListener("mouseup", () => {
isDrawing = false;
isErasing = false;
});
/* Touch logic */
canvas.addEventListener("touchstart", e => {
isTouchActive = true;
hasMoved = false;
mode = null;
touchStartTime = performance.now();
}, { passive: false });
canvas.addEventListener("touchmove", e => {
if (!isTouchActive) return;
e.preventDefault();
if (!hasMoved) {
hasMoved = true;
const elapsed = performance.now() - touchStartTime;
if (elapsed >= ERASE_DELAY) {
mode = "erase";
showEraseIndicator();
} else {
mode = "paint";
}
}
applyPixel(e);
}, { passive: false });
canvas.addEventListener("touchend", () => {
isTouchActive = false;
mode = null;
hideEraseIndicator();
});
function applyPixel(e) {
const { x, y } = getPointerPos(e);
if (x < 0 || y < 0 || x >= logicalSize || y >= logicalSize) return;
if (mode === "erase") {
frames[currentFrame][y][x] = [0, 0, 0];
} else if (mode === "paint") {
const hex = colorPicker.value;
frames[currentFrame][y][x] = [
parseInt(hex.slice(1,3),16),
parseInt(hex.slice(3,5),16),
parseInt(hex.slice(5,7),16)
];
}
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
draw();
}
function addFrame() {
frames.push(copyPrevCheckbox.checked ? cloneFrame(frames[currentFrame]) : createEmptyFrame(logicalSize));
currentFrame = frames.length - 1;
updateFrameLabel();
draw();
}
function deleteFrame() {
if (frames.length === 1) return alert("Cannot delete last frame");
frames.splice(currentFrame, 1);
currentFrame = Math.max(0, currentFrame - 1);
updateFrameLabel();
draw();
}
function prevFrame() {
if (currentFrame > 0) currentFrame--;
updateFrameLabel();
draw();
}
function nextFrame() {
if (currentFrame < frames.length - 1) currentFrame++;
updateFrameLabel();
draw();
}
function updateFrameLabel() {
frameLabel.textContent = `Frame ${currentFrame + 1} / ${frames.length}`;
}
function exportFrames() {
fetch("/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(frames)
}).then(r => r.text()).then(alert);
}
function activateNormalCycle() {
fetch("/normal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: ""
});
}
function showEraseIndicator() {
eraseIndicator.style.opacity = "1";
eraseIndicator.style.transform = "scale(1)";
}
function hideEraseIndicator() {
eraseIndicator.style.opacity = "0";
eraseIndicator.style.transform = "scale(0.8)";
}
document.getElementById("pngInput").addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
const off = document.createElement("canvas");
off.width = logicalSize;
off.height = logicalSize;
const octx = off.getContext("2d");
octx.drawImage(img, 0, 0, logicalSize, logicalSize);
const d = octx.getImageData(0, 0, logicalSize, logicalSize).data;
for (let y = 0; y < logicalSize; y++) {
for (let x = 0; x < logicalSize; x++) {
const i = (y * logicalSize + x) * 4;
frames[currentFrame][y][x] = [d[i], d[i+1], d[i+2]];
}
}
draw();
};
img.src = URL.createObjectURL(file);
});
applySize();
</script>
</body>
</html>
)rawliteral");
}
#endif