Skip to main content

codeBox.js

ESP32-CAM: Stream Video with an Asynchronous Web Server

 



Introduction

This project was developed under two main characteristics: efficiency and scalability.
The original example available on the Arduino IDE, "ESP32/Camera/CameraWebServer.ino" is a great example for beginners and runs well. However, a problem arises when I tried to escalate the project. I decided to rebuild the entire application to adjust to my necessities, rewrite the code, delete unnecessary parts, create a directory, and keep single files (.cpp, .h, .html, .css, .js, etc.)

I used PlarformIO with VSCode to create the project structure, for directory management.

I adapted the server code to run with an asynchronous web server, this gives me the ability to handle multiple connections from different clients simultaneously without blocking the main program flow.

The frontend was developed using responsive design to adapt to different screens, additional for mobile devices was implemented a full-screen mode with responses to landscape and portrait mode, a response to a pinch gesture (two-finger gesture) action to fit the image to the screen, and an overlay menu to modify camera options.

The code and updates can be found on my GitHub.

Prerequisites

  1. The code for this project is adapted to work with an ESP32-CAM AI-THINKER with an OV2460 sensor camera.
  2. ESP32-CAM requires a USB to UART TTL converter of 5v to upload code.
  3. The project was developed using Platformio IDE and VScode. An installation tutorial can be found on the RANDOM NERD TUTORIALS blog

Project Details

Circuit Diagram

ESP32-CAM AI-THINKER connection with USB/TTL converted

Environment configuration

The advantage of using PlatformIO over Arduino IDE is it creates a well-structured project directory to save our files and code.

PlatformIO-VScode project structure

To recognize web files(.html, .js, .css, etc) the ESP32 has to use the filesystem to save those files in the internal memory, for this purpose, we use the LittleFS.h library that's already included on the Arduino core for ESP32, no additional installation is required, but we have to configure the board to use this memory space.

platformio.ini

[env:esp32cam]
platform = espressif32
board = esp32cam
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
; libraries
lib_deps = esphome/ESPAsyncWebServer-esphome@^3.2.2
; explicity set MCU frecuency at 240 MHz(WiFi/BT)
board_build.f_cpu = 240000000L

Microcontroller configuration

The configuration file CameraPins.h contains the GPIO declaration for the ESP32-CAM AI-THINKER with an OV2460 sensor camera.

CameraPins.h

/**
 * GPIO configuration file for ESP32-CAM.
 * Details:
 * - Microcontroller model : ESP32-CAM AI-Thinker
 * - Camera model: OV2640 
 * - Resolution: 2MP
*/

#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27

#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

//4 for flash LED or 33 for normal LED
#define LED_GPIO_NUM       4

To use another esp32-cam model or sensor camera, take a look at the original example available on the Arduino IDE or visit the following links.
The configuration file AsyncWebCamera.h contains the camera's initial setup and the asynchronous stream response.
The code is partially based on the AsyncWebCamera.cpp file developed by the GitHub user me-no-dev.

AsyncWebCamera.h

#include <CameraPins.h>
#include <esp_camera.h>
#include <ESPAsyncWebServer.h>

// this code is partialy based on AsyncWebCamera.cpp developed by user "me-no-dev"
// the original code can be found here: 
// https://gist.github.com/me-no-dev/d34fba51a8f059ac559bf62002e61aa3
typedef struct {
    camera_fb_t * fb;
    size_t index;
} camera_frame_t;

// stream content configuration
#define PART_BOUNDARY "123456789000000000000987654321"
static const char* STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;
static const char* STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";
static const char* STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";    
static const char * JPG_CONTENT_TYPE = "image/jpeg";

// init camera
void Camera_init_cofig(){
    camera_config_t config;
    // camera clock 
    config.ledc_channel = LEDC_CHANNEL_0;
    config.ledc_timer = LEDC_TIMER_0;
    // camera hardware configuration
    config.pin_d0 = Y2_GPIO_NUM;
    config.pin_d1 = Y3_GPIO_NUM;
    config.pin_d2 = Y4_GPIO_NUM;
    config.pin_d3 = Y5_GPIO_NUM;
    config.pin_d4 = Y6_GPIO_NUM;
    config.pin_d5 = Y7_GPIO_NUM;
    config.pin_d6 = Y8_GPIO_NUM;
    config.pin_d7 = Y9_GPIO_NUM;
    config.pin_xclk = XCLK_GPIO_NUM;
    config.pin_pclk = PCLK_GPIO_NUM;
    config.pin_vsync = VSYNC_GPIO_NUM;
    config.pin_href = HREF_GPIO_NUM;
    config.pin_sccb_sda = SIOD_GPIO_NUM;
    config.pin_sccb_scl = SIOC_GPIO_NUM;
    config.pin_pwdn = PWDN_GPIO_NUM;
    config.pin_reset = RESET_GPIO_NUM;
    config.xclk_freq_hz = 20000000;
    // configuration for streaming
    //config.frame_size = FRAMESIZE_UXGA; 
    config.pixel_format = PIXFORMAT_JPEG;
    config.grab_mode = CAMERA_GRAB_LATEST;
    // if PSRAM is present, init with UXGA resolution and higher JPEG quality
    // for larger pre-allocated frame buffer.
    if(psramFound()){
        config.frame_size = FRAMESIZE_UXGA;
        config.fb_location = CAMERA_FB_IN_PSRAM;
        config.jpeg_quality = 8;
        config.fb_count = 2;   
    }
    else {
        // Limit the frame size when PSRAM is not available
        config.frame_size = FRAMESIZE_SVGA;
        config.fb_location = CAMERA_FB_IN_DRAM;
        config.jpeg_quality = 14;
        config.fb_count = 1; 
    }

    // initialize camera
    esp_err_t err = esp_camera_init(&config);
    if (err != ESP_OK) {
        Serial.printf("Camera init failed with error 0x%x", err);
        return;
    }
    else{
        Serial.printf("Camera init successfully!\n");
    }
    // configure camera sensor
    sensor_t * s = esp_camera_sensor_get();
    if(config.pixel_format == PIXFORMAT_JPEG){
        // drop down frame size for higher initial frame rate
        s->set_framesize(s, FRAMESIZE_QVGA);
        // image setup
        s->set_aec2(s,1);
        s->set_aec_value(s,168);
        s->set_agc_gain(s,5);
        s->set_hmirror(s,1);
        // register
        //s->set_reg(s, 0x111, 0x80, 0x80);
  }
}

// stream jpg 
class AsyncJpegStreamResponse: public AsyncAbstractResponse {
    private:
        camera_frame_t _frame;
        size_t _index;
        size_t _jpg_buf_len;
        uint8_t * _jpg_buf;
        uint64_t lastAsyncRequest;
    public:
        AsyncJpegStreamResponse(){
            _callback = nullptr;
            _code = 200;
            _contentLength = 0;
            _contentType = STREAM_CONTENT_TYPE;
            _sendContentLength = false;
            _chunked = true;
            _index = 0;
            _jpg_buf_len = 0;
            _jpg_buf = NULL;
            lastAsyncRequest = 0;
            memset(&_frame, 0, sizeof(camera_frame_t));
        }
        ~AsyncJpegStreamResponse(){
            if(_frame.fb){
                if(_frame.fb->format != PIXFORMAT_JPEG){
                    free(_jpg_buf);
                }
                esp_camera_fb_return(_frame.fb);
            }
        }
        bool _sourceValid() const {
            return true;
        }
        virtual size_t _fillBuffer(uint8_t *buf, size_t maxLen) override {
            size_t ret = _content(buf, maxLen, _index);
            if(ret != RESPONSE_TRY_AGAIN){
                _index += ret;
            }
            return ret;
        }
        size_t _content(uint8_t *buffer, size_t maxLen, size_t index){
            // available PSRAM and DRAM memory
            //size_t freeDRAM = heap_caps_get_free_size(MALLOC_CAP_8BIT);
            //size_t freePSRAM = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);

            if(!_frame.fb || _frame.index == _jpg_buf_len){
                if(index && _frame.fb){
                    uint64_t end = (uint64_t)micros();
                    int fp = (end - lastAsyncRequest) / 1000;
                    //log_printf("Size: %uKB, Time: %ums (%.1ffps)\n", _jpg_buf_len/1024, fp);
                    //log_printf("PSRAM:%u, DRAM:%u\n",freePSRAM/1024, freeDRAM/1024);
                    lastAsyncRequest = end;
                    if(_frame.fb->format != PIXFORMAT_JPEG){
                        free(_jpg_buf);
                    }
                    esp_camera_fb_return(_frame.fb);
                    _frame.fb = NULL;
                    _jpg_buf_len = 0;
                    _jpg_buf = NULL;
                }
                if(maxLen < (strlen(STREAM_BOUNDARY) + strlen(STREAM_PART) + strlen(JPG_CONTENT_TYPE) + 8)){
                    //log_w("Not enough space for headers");
                    return RESPONSE_TRY_AGAIN;
                }
                // get frame
                _frame.index = 0;

                _frame.fb = esp_camera_fb_get();
                if (_frame.fb == NULL) {
                    //log_e("Camera frame failed");
                    return 0;
                }

                if(_frame.fb->format != PIXFORMAT_JPEG){
                    unsigned long st = millis();
                    bool jpeg_converted = frame2jpg(_frame.fb, 80, &_jpg_buf, &_jpg_buf_len);
                    if(!jpeg_converted){
                        //log_e("JPEG compression failed");
                        esp_camera_fb_return(_frame.fb);
                        _frame.fb = NULL;
                        _jpg_buf_len = 0;
                        _jpg_buf = NULL;
                        return 0;
                    }
                    //log_i("JPEG: %lums, %uB", millis() - st, _jpg_buf_len);
                } else {
                    _jpg_buf_len = _frame.fb->len;
                    _jpg_buf = _frame.fb->buf;
                }

                // send boundary
                size_t blen = 0;
                if(index){
                    blen = strlen(STREAM_BOUNDARY);
                    memcpy(buffer, STREAM_BOUNDARY, blen);
                    buffer += blen;
                }
                // send header
                size_t hlen = sprintf((char *)buffer, STREAM_PART, JPG_CONTENT_TYPE, _jpg_buf_len);
                buffer += hlen;
                // send frame
                hlen = maxLen - hlen - blen;
                if(hlen > _jpg_buf_len){
                    maxLen -= hlen - _jpg_buf_len;
                    hlen = _jpg_buf_len;
                }
                memcpy(buffer, _jpg_buf, hlen);
                _frame.index += hlen;
                return maxLen;
            }

            size_t available = _jpg_buf_len - _frame.index;
            if(maxLen > available){
                maxLen = available;
            }
            memcpy(buffer, _jpg_buf+_frame.index, maxLen);
            _frame.index += maxLen;

            return maxLen;
        }
};



The main.cpp file contains all the necessary code to run an asynchronous web server and handle the client's requests.

main.cpp

#include <Arduino.h>
// esp32cam with async web server
#include <AsyncWebCamera.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
// wifi credentials (create your own file on include/Secrets.h)
#include <Secrets.h>
// wifi library
#include <WiFi.h>
// upload files to filesystem
#include <LittleFS.h>

//Web server credentials
const char* ssid = WIFI_SSID;
const char* password = WIFI_PASSWORD;
AsyncWebServer server(80);

//LittleFs filesystem
#define FORMAT_LITTLEFS_IF_FAILED true

//Esp32-cam internal led
int led_channel = 2;
int led_resolution = 8;
int led_frequency = 980;
int led_duty = 0;
int max_intensity = 255;

/*FUNCTION DECLARATION------------------------------------------------------------------------------------------------------*/
bool initWiFi();
void notFound(AsyncWebServerRequest *request);
void StreamJpg(AsyncWebServerRequest *request);
void SetCameraVar(AsyncWebServerRequest *request);
void GetCameraStatus(AsyncWebServerRequest *request);
void SetXclkValue(AsyncWebServerRequest *request);
void SetupCameraLed();
/*--------------------------------------------------------------------------------------------------------------------------*/

/*VOID SETUP CONFIGURATION--------------------------------------------------------------------------------------------------*/
void setup() {  
  Serial.begin(115200);
  // initialize camera led
  SetupCameraLed();
  // initialize camera (ESP32-CAM AI-Thinker configuration)
  Camera_init_cofig();
  // Mount LittleFS filesystem
  if(!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED)){
    Serial.println("LITTLEFS Mount Failed");
    return;
  }
  // initialize wifi
  initWiFi();

  //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  /*
  // normal web files
  server.serveStatic("/static/css/style.css", LittleFS, "/static/css/style.css");
  server.serveStatic("/static/js/main.js", LittleFS, "/static/js/main.js");
  server.serveStatic("/static/icons/", LittleFS, "/static/icons/");
  server.on("/", HTTP_ANY, [](AsyncWebServerRequest *request){
    request->send(LittleFS,"/stream.html","text/html", false);
  });
  */
  //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  
  // compressed web files
  server.on("/static/js/main.js", HTTP_GET, [](AsyncWebServerRequest *request) {
    AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/static/js/main.js.gz", "application/javascript");
    response->addHeader("Content-Encoding", "gzip");
    request->send(response);
  });
  server.on("/static/css/style.css", HTTP_GET, [](AsyncWebServerRequest *request) {
    AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/static/css/style.css.gz", "text/css");
    response->addHeader("Content-Encoding", "gzip");
    request->send(response);
  });
  server.serveStatic("/static/icons/", LittleFS, "/static/icons/").setCacheControl("max-age=300");
  //server.serveStatic("/", LittleFS, "/").setDefaultFile("stream.html.gz").setCacheControl("max-age=200");
  server.on("/", HTTP_ANY, [](AsyncWebServerRequest *request){
    AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/stream.html.gz", "text/html");
    response->addHeader("Content-Encoding", "gzip");
    request->send(response);
  });
  
  //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

  // Handle URLs
  server.onNotFound(notFound);
  server.on("/stream", HTTP_GET, StreamJpg);
  server.on("/control", HTTP_GET, SetCameraVar);
  server.on("/status", HTTP_GET, GetCameraStatus);
  server.on("/xclk", HTTP_GET, SetXclkValue);
  server.begin();
}
/*--------------------------------------------------------------------------------------------------------------------------*/

/*LOOP----------------------------------------------------------------------------------------------------------------------*/
void loop(){
  //camera led control
  ledcWrite(led_channel,led_duty);  
}
/*--------------------------------------------------------------------------------------------------------------------------*/


/*FUNCTIONS-----------------------------------------------------------------------------------------------------------------*/
// URL not found
void notFound(AsyncWebServerRequest *request) {
    request->send(404, "text/plain", "Not found");
}
// stream jpg images over async web server
void StreamJpg(AsyncWebServerRequest *request){
  AsyncJpegStreamResponse *response = new AsyncJpegStreamResponse();
  if(!response){
    // not implemented : server not support the functionality
    request->send(501);
    return;
  }
  response->addHeader("Access-Control-Allow-Origin", "*");
  response->addHeader("X-Framerate", "60");
  request->send(response);
}

// set xclk(external clock) value. 
void SetXclkValue(AsyncWebServerRequest *request){
  if(!request->hasArg("xclk")){
    request->send(404, "text/plain", "Not found");
    return;
  }
  int xclk = atoi(request->arg("xclk").c_str());
  sensor_t * s = esp_camera_sensor_get();
  if(s == NULL){
    // not implemented : server not support the functionality
    request->send(501);
    return;
  }
  // xclk value in MHz, default 20MHz
  int res = s->set_xclk(s,LEDC_TIMER_0,xclk);
  
  AsyncWebServerResponse * response = request->beginResponse(200);
  response->addHeader("Access-Control-Allow-Origin", "*");
  request->send(response);
}

// change camera sensor values
void SetCameraVar(AsyncWebServerRequest *request){
  if(!request->hasArg("var") || !request->hasArg("val")){
    request->send(404,"text/plain", "Not found");
    return;
  }
  String var = request->arg("var");
  const char * variable = var.c_str();
  int val = atoi(request->arg("val").c_str());
  sensor_t * s = esp_camera_sensor_get();
  if(s == NULL){
    // not implemented : server not support the functionality
    request->send(501);
    return;
  }
  int res = 0;
  if(!strcmp(variable, "framesize")) res = s->set_framesize(s, (framesize_t)val);
  else if(!strcmp(variable, "quality")) res = s->set_quality(s, val);
  else if(!strcmp(variable, "contrast")) res = s->set_contrast(s, val);
  else if(!strcmp(variable, "brightness")) res = s->set_brightness(s, val);
  else if(!strcmp(variable, "saturation")) res = s->set_saturation(s, val);
  else if(!strcmp(variable, "gainceiling")) res = s->set_gainceiling(s, (gainceiling_t)val);
  else if(!strcmp(variable, "colorbar")) res = s->set_colorbar(s, val);
  else if(!strcmp(variable, "awb")) res = s->set_whitebal(s, val);
  else if(!strcmp(variable, "agc")) res = s->set_gain_ctrl(s, val);
  else if(!strcmp(variable, "aec")) res = s->set_exposure_ctrl(s, val);
  else if(!strcmp(variable, "hmirror")) res = s->set_hmirror(s, val);
  else if(!strcmp(variable, "vflip")) res = s->set_vflip(s, val);
  else if(!strcmp(variable, "awb_gain")) res = s->set_awb_gain(s, val);
  else if(!strcmp(variable, "agc_gain")) res = s->set_agc_gain(s, val);
  else if(!strcmp(variable, "aec_value")) res = s->set_aec_value(s, val);
  else if(!strcmp(variable, "aec2")) res = s->set_aec2(s, val);
  else if(!strcmp(variable, "dcw")) res = s->set_dcw(s, val);
  else if(!strcmp(variable, "bpc")) res = s->set_bpc(s, val);
  else if(!strcmp(variable, "wpc")) res = s->set_wpc(s, val);
  else if(!strcmp(variable, "raw_gma")) res = s->set_raw_gma(s, val);
  else if(!strcmp(variable, "lenc")) res = s->set_lenc(s, val);
  else if(!strcmp(variable, "special_effect")) res = s->set_special_effect(s, val);
  else if(!strcmp(variable, "wb_mode")) res = s->set_wb_mode(s, val);
  else if(!strcmp(variable, "ae_level")) res = s->set_ae_level(s, val);
  else if(!strcmp(variable, "led_intensity")){
    led_duty = val;
  }
  else {
    request->send(404);
    return;
    //log_e("unknown setting %s", var.c_str());
  }
  //log_d("Got setting %s with value %d. Res: %d", var.c_str(), val, res);
  AsyncWebServerResponse * response = request->beginResponse(200);
  response->addHeader("Access-Control-Allow-Origin", "*");
  request->send(response);
};

// get camera sensor values 
void GetCameraStatus(AsyncWebServerRequest *request){
  static char json_response[1024];
  sensor_t * s = esp_camera_sensor_get();
  if(s == NULL){
    // not implemented : server not support the functionality
    request->send(501);
    return;
  }
  char * p = json_response;
  *p++ = '{';
  // register only for 0v2640 sensor camera
  p+=sprintf(p, "\"0xd3\":%u,", s->get_reg(s, 0xd3, 0xFF));
  p+=sprintf(p, "\"0x111\":%u,", s->get_reg(s, 0x111, 0xFF));
  p+=sprintf(p, "\"0x132\":%u,", s->get_reg(s, 0x132, 0xFF));
  // external clock
  p+=sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000);
  // image format (jpg = 4)
  p+=sprintf(p, "\"pixformat\":%u,", s->pixformat);
  // image quality
  p+=sprintf(p, "\"framesize\":%u,", s->status.framesize);
  p+=sprintf(p, "\"quality\":%u,", s->status.quality);
  // sensor camera
  p+=sprintf(p, "\"brightness\":%d,", s->status.brightness);
  p+=sprintf(p, "\"contrast\":%d,", s->status.contrast);
  p+=sprintf(p, "\"saturation\":%d,", s->status.saturation);
  p+=sprintf(p, "\"special_effect\":%u,", s->status.special_effect);
  p+=sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode);
  p+=sprintf(p, "\"awb\":%u,", s->status.awb);
  p+=sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain);
  p+=sprintf(p, "\"aec\":%u,", s->status.aec);
  p+=sprintf(p, "\"aec2\":%u,", s->status.aec2);
  p+=sprintf(p, "\"ae_level\":%d,", s->status.ae_level);
  p+=sprintf(p, "\"aec_value\":%u,", s->status.aec_value);
  p+=sprintf(p, "\"agc\":%u,", s->status.agc);
  p+=sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain);
  p+=sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling);
  p+=sprintf(p, "\"bpc\":%u,", s->status.bpc);
  p+=sprintf(p, "\"wpc\":%u,", s->status.wpc);
  p+=sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma);
  p+=sprintf(p, "\"lenc\":%u,", s->status.lenc);
  p+=sprintf(p, "\"hmirror\":%u,", s->status.hmirror);
  p+=sprintf(p, "\"vflip\":%u,", s->status.vflip);
  p+=sprintf(p, "\"dcw\":%u,", s->status.dcw);
  p+=sprintf(p, "\"colorbar\":%u", s->status.colorbar);
  // microcontroller internal led
  p+=sprintf(p, ",\"led_intensity\":%u", led_duty);
  *p++ = '}';
  *p++ = 0;
  
  AsyncWebServerResponse * response = request->beginResponse(200, "application/json", json_response);
  response->addHeader("Access-Control-Allow-Origin", "*");
  request->send(response);
}

// esp32-cam internal led configuration
void SetupCameraLed(){
  pinMode(LED_GPIO_NUM,OUTPUT);
  ledcSetup(led_channel, led_frequency, led_resolution);
  ledcAttachPin(LED_GPIO_NUM, led_channel);    
}

// connect to wifi
bool initWiFi(){
  // web server as Station mode:
  // we can  request information from the internet 
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid,password);
  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
    Serial.printf("WiFi Failed!\n");
  }
  else{
    Serial.print("WiFi connected succesfully!");
    Serial.println(WiFi.localIP());
  }
  return true;
}

/*--------------------------------------------------------------------------------------------------------------------------*/
The web files (.html, .js, .css) make possible the client-side interaction with the server. I used responsive design to make a web page more user-friendly and capable of adapting to different screens.

stream.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!--css files-->
    <link rel="stylesheet" type="text/css" href="./static/css/style.css">
    <!--js files-->
    <script defer src="./static/js/main.js"></script>
    <title>Video streaming with responsive design</title> 
</head>
<body>
    <!--stream images-->
    <section>
        <div id="main-container">
            <!--navigation menu-->
            <div id="navbar">
                <div id="menu-button-container">
                    <button type="button" class="" id="btn-menu"><img src="./static/icons/menu.svg"></button>
                </div>
                <!--fs:full screen-->
                <div id="fs-button-container"> 
                    <button type="button" class="" id="btn-screen" hidden><span id="fs-status" hidden>normal</span><img id="fs-icon" src="./static/icons/full-screen.svg"></button>
                </div>
            </div>
            <!--ss: star/stop stream-->
            <div id="ss-button-container">
                <button type="button" class="" id="btn-stream">start stream</button>
            </div>
            <!--content loading message-->
            <div id="loading-container" hidden>
                <p>Stream content not available</p>
            </div>
            <!--content loaded-->
            <div id="stream-container">
                <img id="stream" src="" hidden crossorigin >
            </div>
            <!--camera configuration options-->
            <div id="menu-opt-container" hidden>
                <div id="menu-opt-header">
                    <h3>Camera menu options</h3>
                    <button type="button" class="btn-size btn-style" id="btn-close"><img src="./static/icons/close.svg"></button>
                </div>
                <div id="menu-opt-list">
                    <!--camera clock signal-->
                    <div class="input-group" id="set-xclk-group">
                        <label for="set-xclk">XCLK MHz</label>
                        <div class="text">
                            <input id="xclk-value" type="text" minlength="1" maxlength="2" size="2" value="20">
                        </div>
                        <button class="inline-button" id="set-xclk">Set</button>
                    </div>
                    <!--frame size-->
                    <div class="input-group" id="framesize-group">
                        <label for="framesize">Resolution</label>
                        <select id="framesize" class="default-action">
                            <option value="12">SXGA(1280x1024)</option>
                            <option value="11">HD(1280x720)</option>
                            <option value="8">VGA(640x480)</option>
                            <option value="5">QVGA(320x240)</option>
                            <option value="4">240x240</option>
                        </select>
                    </div>
                    <!--image quality-->
                    <div class="input-group" id="quality-group">
                        <label for="quality">Quality</label>
                        <div class="slider-container">
                            <input type="range" id="quality" min="4" max="63" value="8" class="default-action">
                            <output id="quality-value"></output>
                        </div>
                    </div>
                    <!--image brightness-->
                    <div class="input-group" id="brightness-group">
                        <label for="brightness">Brightness</label>
                        <div class="slider-container">
                            <input type="range" id="brightness" min="-2" max="2" value="0" class="default-action">
                            <output id="brightness-value"></output>
                        </div>
                    </div>
                    <!--image contrast-->
                    <div class="input-group" id="contrast-group">
                        <label for="contrast">Contrast</label>
                        <div class="slider-container">
                            <input type="range" id="contrast" min="-2" max="2" value="0" class="default-action">
                            <output id="contrast-value"></output>
                        </div>
                    </div>
                    <!--image saturation-->
                    <div class="input-group" id="saturation-group">
                        <label for="saturation">Saturation</label>
                        <div class="slider-container">
                            <input type="range" id="saturation" min="-2" max="2" value="0" class="default-action">
                            <output id="saturation-value"></output>
                        </div>
                    </div>
                    <!--image effect-->
                    <div class="input-group" id="special_effect-group">
                        <label for="special_effect">Special Effect</label>
                        <select id="special_effect" class="default-action">
                            <option value="0" selected="selected">No Effect</option>
                            <option value="2">Grayscale</option>
                        </select>
                    </div>
                    <!--image horizontal mirror-->
                    <div class="input-group" id="hmirror-group">
                        <label for="hmirror">Image Mirror</label>
                        <div class="switch">
                            <input id="hmirror" type="checkbox" class="default-action" checked="checked">
                            <label class="slider" for="hmirror"></label>
                        </div>
                    </div>
                    <!--image vertical flip-->
                    <div class="input-group" id="vflip-group">
                        <label for="vflip">image Flip</label>
                        <div class="switch">
                            <input id="vflip" type="checkbox" class="default-action" checked="checked">
                            <label class="slider" for="vflip"></label>
                        </div>
                    </div>
                    <!--led intensity-->
                    <div class="input-group" id="led-group">
                        <label for="led_intensity">LED Intensity</label>
                        <div class="slider-container">
                            <input type="range" id="led_intensity" min="0" max="255" value="0" class="default-action">
                            <output id="led-value"></output>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </section>
</body>
</html>

style.css

/*
* stream jpeg images
*/
section{
    /*delete default space in the tag*/
    margin: 0;
    position: relative;
}
#main-container{
    /*stream container in the middle of the screen*/
    height: 100vh;
    display: flex;
    justify-content: center;
}
#stream-container{
    position: absolute;
    top:4em;
    background-color: rgb(79, 77, 77);
    width: calc(100vw - 80px);
    height: calc(0.70*(100vw - 80px));
    display: flex;
    align-items: center;
    justify-content: center;
}
#stream{
    /*image fits into the container*/
    max-width: 100%;
    max-height: 100%;
}
#navbar{
    width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0px 10px 0px 10px;
    position: absolute;
    top:10px;
    z-index: 1;
}
#ss-button-container{
    z-index:1;
}
#btn-menu, #btn-screen,#btn-close{
    border: none;
    background-color: transparent;
    width: 44px;
    height: 44px;
}
#btn-stream{
    border: solid green 2px;
    border-radius: 5%;
    background-color: transparent;
    color: black;
    height: 44px;
    width: 120px;
    position: absolute;
    top:10px;
    left:50%;
    transform:translateX(-50%);
}
#btn-menu > img{
    /*icon white*/
    /*filter: invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%);*/
    /*icon black*/
    filter: invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%);
}
#btn-screen > img{
    /*icon black*/
    filter: invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%);
}
#loading-container{
    position: absolute;
    top: 4.5em;
    z-index: 1;
    color: white;
}
/*
* camera menu options
*/
#menu-opt-container{
    /*container position*/
    position: absolute;
    top:80px;
    width:330px;
    /*center container*/
    left: 50%;
    transform: translateX(-50%);
    /*style container*/
    border: solid 1px gray;
    background: rgba(128,128,128,.2);
    color:black;
    border-radius: 3%;
    padding: 10px 15px 10px 15px;
    z-index: 1;
}
#menu-opt-container > #menu-opt-list> .input-group > label{
    width:110px;
}
#menu-opt-header{
    display: flex;
    justify-content: space-between;
    margin-bottom: 15px;
    border-bottom: solid 1px gray;
}
#framesize,#special_effect,.slider-container{
    height: 35px;
    width: 180px;
}
#framesize,#special_effect,#hmirror,#vflip{
    /*background: rgba(224, 224, 224, 0.1);*/
    background:white;
    border: none;
}
/*check buttons as switch style*/
.switch {
    position: relative;
    display: inline-block;
    width: 70px;
    height: 30px;
    margin: 5px 0px;
}
.switch input {
    opacity: 0;
    width: 0;
    height: 0;
}
.slider {
    position: absolute;
    cursor: pointer;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: grey;
    transition: 0.4s;
    border-radius: 22px;
}
.slider:before {
    position: absolute;
    content: "";
    height: 22px;
    width: 22px;
    left: 4px;
    bottom: 4px;
    background-color: white;
    transition: 0.4s;
    border-radius: 50%;
}
input:checked + .slider {
    background-color: #0d6efd;
}
input:checked + .slider:before {
    transform: translateX(38px);
}

.input-group{
    padding-top: 4px;
    padding-bottom: 4px;
    display:flex;
    align-items: center;
}
.slider-container{
    display: flex;
    align-items: center;
    justify-content: space-between;
}
#btn-close{
    position: relative;
    bottom: 5px;
    left: 0px;
}
#set-xclk{
    position:relative;
    left: 5px;
    border: none;
    border-radius: 5px;
    background-color: #0d6efd;
    color: white;
}
#xclk-value,#set-xclk{
    width: 60px;
    height: 30px;
    margin: 5px 0px;
    border-radius:20px;
    border:none;
    text-align: center;
}
/*arrow icon and left border on select options*/
#framesize,#special_effect{
    border-radius: 20px;
    padding-left: 8px;
    -moz-appearance:none; /* Firefox */
    -webkit-appearance:none; /* Safari and Chrome */
    appearance:none;
    background-image:
    linear-gradient(45deg, transparent 50%, gray 50%),
    linear-gradient(135deg, gray 50%, transparent 50%),
    linear-gradient(to right, #ccc, #ccc);
    background-position:
    calc(100% - 20px) calc(1em + 0px),
    calc(100% - 15px) calc(1em + 0px),
    calc(100% - 2.2em) 0.3em;
    background-size:
    5px 5px,
    5px 5px,
    1px 1.5em;
    background-repeat: no-repeat;
}

@media only screen and (max-height:430px) and (orientation:landscape){
    #stream-container{
        width: calc(100vh - 50px);
        height: calc(0.70*(100vh - 50px));
    }
    #stream{
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
    #menu-opt-list{
        height: calc(0.4*100vh);
        overflow-y:scroll;
    }
    #menu-opt-container{
        width:360px;
        height: auto;
        /*center container*/
        left: 50%;
        transform: translateX(-50%);
    }
}
@media only screen and (min-height:431px) and (max-height:800px) and (orientation:landscape){
    #stream-container{
        width: calc(100vh - 82px);
        height: calc(0.70*(100vh - 82px));
    }
    #stream{
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
    #menu-opt-list{
        height: calc(0.55*100vh);
        overflow-y:scroll;
    }
    #menu-opt-container{
        width:360px;
        /*center container*/
        left: 50%;
        transform: translateX(-50%);
    }
}
@media only screen and (min-height:801px){
    #stream-container{
        width: calc(100vh - 94px);
        height: calc(0.70*(100vh - 94px));
    }
    #stream{
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
    #menu-opt-container{
        width:360px;
    }
}
@media (orientation: portrait){
    #main-container{
        align-items:center;
    }
    #stream-container{
        width: calc(100vw - 80px);
        height: calc(0.70*(100vw - 80px));
    }
    #stream{
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
}

main.js

/**
 * Variable declaration
 * ---------------------------------------------------------------------
 */
// image variables from html
const mainContainer = document.getElementById('main-container');
const view = document.getElementById('stream');
const streamContainer = document.getElementById('stream-container');
// buttons from html
const btnStream = document.getElementById('btn-stream');
const btnFullScreen = document.getElementById('btn-screen');
const btnMenu = document.getElementById('btn-menu');
const btnClose = document.getElementById('btn-close');
const btnXclk = document.getElementById('set-xclk');
// loading message
var loadingMsg = document.getElementById('loading-container');
// full screen 
var fullScreenStatus = document.getElementById('fs-status');
var fullScreenIcon = document.getElementById('fs-icon');
// menu options
const xclkValue = document.getElementById('xclk-value');
const menuContainer = document.getElementById('menu-opt-container');
const framesizeSelect = document.getElementById('framesize');
const qualityInput = document.getElementById('quality');
const qualityValue = document.getElementById('quality-value'); 
const brightnessInput = document.getElementById('brightness');
const brightnessValue = document.getElementById('brightness-value'); 
const contrastInput = document.getElementById('contrast');
const contrastValue = document.getElementById('contrast-value');
const saturationInput = document.getElementById('saturation');
const saturationValue = document.getElementById('saturation-value');
const ledIntensity = document.getElementById('led_intensity');
const ledValue = document.getElementById('led-value');

// pinch zoom gesture variables
// code partially based on :
// github.com/mdn/dom-examples/blob/main/pointerevents/Pinch_zoom_gestures.html
// 
// Global variables to cache event state
var evCache = new Array();
var prevDiff = -1;
//----------------------------------------------------------------------

/**
 * Event listener
 * ---------------------------------------------------------------------
 */
// start stop stream with button
document.addEventListener('DOMContentLoaded',()=>{
  // show initial message over stream area
  loadingMsg.hidden = false;
  //set camera initial values
  cameraInitialValues();
})

// adjust screen with orientation
if ('orientation' in screen){
  screen.addEventListener('change',()=>{
    screenStatus(fullScreenStatus.innerHTML);
  });
}

// return to normal mode(close full screen)
// if back button is pressed instead of full screen button
document.addEventListener('fullscreenchange',()=>{
  // exit full screen
  if(document.fullscreenElement === null){
    fullScreenIcon.src = './static/icons/full-screen.svg';
    exitFullscreen();
    fullScreenStatus.innerHTML = 'normal';
    screenStatus(fullScreenStatus.innerHTML);
  }
})
// menu options
// open/close menu options
btnMenu.addEventListener('click',()=>{
  menuContainer.hidden = false;
  menuContainer.style.zIndex = 1000;
  view.style.zIndex = 0;
  //console.log("menu opened");
})
btnClose.addEventListener('click',()=>{
  menuContainer.hidden = true;
  //console.log("menu closed")
})
// shows image quality value 
qualityInput.addEventListener("input",(event)=>{
  qualityValue.textContent = event.target.value;
})
// shows image brightness value 
brightnessInput.addEventListener("input",(event)=>{
  brightnessValue.textContent = event.target.value;
})
// shows image contrast value 
contrastInput.addEventListener("input",(event)=>{
  contrastValue.textContent = event.target.value;
})
// shows image saturation value 
saturationInput.addEventListener("input",(event)=>{
  saturationValue.textContent = event.target.value;
})
// shows led intensity value
ledIntensity.addEventListener("input",(event)=>{
  ledValue.textContent = event.target.value;
})
// change image configuration on tag change
document.querySelectorAll('.default-action').forEach((el)=>{
  el.addEventListener('change',()=>{
    updateConfig(el);
  })
})
// change xclk on button click
btnXclk.addEventListener('click',()=>{
  let xclkValue = document.getElementById('xclk-value').value;
  setXclkValue(xclkValue);
})

// start/stop stream
btnStream.addEventListener('click',()=>{
  const streamEnabled = btnStream.innerHTML === 'stop stream';
  if(streamEnabled){
    stopStream();
    loadingMsg.hidden = false;
    btnFullScreen.hidden = true;
    if(fullScreenStatus.innerHTML === 'full'){
      screenStatus(fullScreenStatus.innerHTML);
      fullScreenStatus.innerHTML = 'normal';
      fullScreenIcon.src = './static/icons/full-screen.svg';
      exitFullscreen();
    }
  }
  else{
    startStream();
    loadingMsg.hidden = true;
    btnFullScreen.hidden = false;
  }
})

// enter/exit full screen mode
btnFullScreen.addEventListener('click',()=>{
  if(fullScreenStatus.innerHTML === 'normal'){
    fullScreenStatus.innerHTML = 'full';
    fullScreenIcon.src = './static/icons/exit-full-screen.svg';
    screenStatus(fullScreenStatus.innerHTML);
    requestFullscreen(mainContainer);
  } 
  else if(fullScreenStatus.innerHTML === 'full'){
    fullScreenStatus.innerHTML = 'normal';
    fullScreenIcon.src = './static/icons/full-screen.svg';
    screenStatus(fullScreenStatus.innerHTML);
    exitFullscreen();
  }
})

//----------------------------------------------------------------------

/**
 * User functions
 * ---------------------------------------------------------------------
 */
// pinch zoom gesture
// code partially based on :
// github.com/mdn/dom-examples/blob/main/pointerevents/Pinch_zoom_gestures.html
//
function pointerdownHandler(ev) {
  // The pointerdown event signals the start of a touch interaction.
  // This event is cached to support 2-finger gestures
  evCache.push(ev);
}
function pointermoveHandler(ev) {
  // This function implements a 2-pointer horizontal pinch/zoom gesture.
  //
  // If the distance between the two pointers has increased (zoom in),
  // the taget element (image) fit into the screen and if the
  // distance is decreasing (zoom out), the target element (image) return to
  // its original size
  //
  // Find this event in the cache and update its record with this event
  for (var i = 0; i < evCache.length; i++) {
    if (ev.pointerId == evCache[i].pointerId) {
      evCache[i] = ev;
      break;
    }
  }
  // If two pointers are down, check for pinch gestures
  if (evCache.length == 2) {
    // Calculate the distance between the two pointers
    var curDiff = Math.sqrt(
      Math.pow(evCache[1].clientX - evCache[0].clientX, 2) + 
      Math.pow(evCache[1].clientY - evCache[0].clientY, 2)
    );
    if (prevDiff > 0) {
      if (curDiff > prevDiff) {
        // The distance between the two pointers has increased
        //console.log("increase");
        ev.target.style.width = "100%";
        ev.target.style.height = "100%";
        ev.target.style.objectFit = "cover"; 
      }
      if (curDiff < prevDiff) {
        // The distance between the two pointers has decreased
        //console.log("decrease");
        ev.target.style.objectFit = "initial";
        if ('orientation' in screen && screen.orientation.type === 'portrait-primary'){
          ev.target.style.width = "100%";
          ev.target.style.height = "auto";  
        }
        else{
          ev.target.style.width = "auto";
          ev.target.style.height = "100%";
        }
        
      }
    }
    // Cache the distance for the next move event
    prevDiff = curDiff;
  }
}
function pointerupHandler(ev) {
  // Remove this pointer from the cache and reset the target's
  removeEvent(ev);
  // If the number of pointers down is less than two then reset diff tracker
  if (evCache.length < 2) prevDiff = -1;
}
function removeEvent(ev) {
  // Remove this event from the target's cache
  for (var i = 0; i < evCache.length; i++) {
    if (evCache[i].pointerId == ev.pointerId) {
      evCache.splice(i, 1);
      break;
    }
  }
}
const setupFullScreenEventListeners = (add) => {
  const action = add ? 'addEventListener' : 'removeEventListener';
  view[action]("pointerdown", pointerdownHandler);
  view[action]("pointermove", pointermoveHandler);
  view[action]("pointerup", pointerupHandler);
  view[action]("pointercancel", pointerupHandler);
  view[action]("pointerout", pointerupHandler);
  view[action]("pointerleave", pointerupHandler);
};

// full screen mode 
function requestFullscreen(element) {
  if (element.requestFullscreen) {
    element.requestFullscreen().catch(err => {
      //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (element.mozRequestFullScreen) { // Firefox
    element.mozRequestFullScreen().catch(err => {
      //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (element.webkitRequestFullscreen) { // Chrome, Safari, and Opera
    element.webkitRequestFullscreen().catch(err => {
      //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (element.msRequestFullscreen) { // IE/Edge
    element.msRequestFullscreen().catch(err => {
      //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
    });
  }
}
//exit full screen mode
function exitFullscreen() {
  if (document.exitFullscreen) {
    document.exitFullscreen().catch(err => {
      //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (document.mozCancelFullScreen) { // Firefox
    document.mozCancelFullScreen().catch(err => {
      //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (document.webkitExitFullscreen) { // Chrome, Safari, and Opera
    document.webkitExitFullscreen().catch(err => {
      //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`);
    });
  } else if (document.msExitFullscreen) { // IE/Edge
    document.msExitFullscreen().catch(err => {
      //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`);
    });
  }
}

// simple stream function
function startStream(){
  // start stream 
  var streamUrl = document.location.origin;
  btnStream.innerHTML = 'stop stream';
  btnStream.style.border = 'solid red 2px';
  view.hidden = false;
  view.src = `${streamUrl}/stream`
  // upload an image to the data folder to use instead of the stream for debugging purposes 
  //view.src = "./test.jpg";  
}

// stop stream
function stopStream(){
  // stop stream 
  btnStream.innerHTML = 'start stream';
  btnStream.style.border = 'solid green 2px';
  window.stop();
  view.hidden = true;
  view.src="";
}

// landscape/portrait image container
function normalLandscape(){
  var landscapeMin = window.matchMedia('only screen and (max-height:430px) and (orientation:landscape)');
  var lanscaspeMid = window.matchMedia('only screen and (min-height:431px) and (max-height:800px) and (orientation:landscape)');
  var landscapeMax = window.matchMedia('only screen and (min-height:801px)');
  
  streamContainer.style.top = '4em';
  streamContainer.style.width = 'calc(100vh - 50px)';
  streamContainer.style.height = 'calc(0.70*(100vh - 50px))';

  if (landscapeMin.matches){
    streamContainer.style.width = 'calc(100vh - 50px)';
    streamContainer.style.height = 'calc(0.70*(100vh - 50px))';
  }
  else if(lanscaspeMid.matches){
    streamContainer.style.width = 'calc(100vh - 82px)';
    streamContainer.style.height = 'calc(0.70*(100vh - 82px))';
  }
  else if(landscapeMax.matches){
    streamContainer.style.width = 'calc(100vh - 94px)';
    streamContainer.style.height = 'calc(0.70*(100vh - 94px))';
  }
  view.style.width = '100%';
  view.style.height = '100%';
  view.style.objectFit = 'cover';

  btnStream.style.color = 'black';
  btnFullScreen.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  btnMenu.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  btnClose.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  menuContainer.style.color = 'black';
  //console.log("normal landscape");
}
function normalPortrait(){
  streamContainer.style.width = 'calc(100vw - 80px)';
  streamContainer.style.height = 'calc(0.70*(100vw - 80px))';
  streamContainer.style.top = '4em';
  
  view.style.width = '100%';
  view.style.height = '100%';
  view.style.objectFit = 'cover';

  btnStream.style.color = 'black';
  btnFullScreen.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  btnMenu.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  btnClose.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)';
  menuContainer.style.color = 'black';
  //console.log("normal portrait");
}
function fullLandscape(){
  streamContainer.style.width = '100vw';
  streamContainer.style.height = '100vh';
  streamContainer.style.top = '0em';
  
  view.style.height = '100vh';
  view.style.width = 'auto';
  
  btnStream.style.color = 'white';
  btnFullScreen.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  btnMenu.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  btnClose.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  menuContainer.style.color = 'white';
  //console.log("full landscape");
}
function fullPortrait(){
  streamContainer.style.width = '100vw';
  streamContainer.style.height = '100vh';
  streamContainer.style.top = '0em';
  
  view.style.width = '100vw';
  view.style.height = 'auto'; 

  btnStream.style.color = 'white';
  btnFullScreen.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  btnMenu.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  btnClose.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)';
  menuContainer.style.color = 'white';
  //console.log("full portrait");
}

// control actions on screen rotation 
function screenStatus(status){
  if (status === 'full'){
    setupFullScreenEventListeners(true);
    if(screen.orientation.type.startsWith('landscape')){
      fullLandscape();
    }
    else if(screen.orientation.type.startsWith('portrait')){
      fullPortrait();
    }
  }
  else if (status === 'normal'){
    setupFullScreenEventListeners(false);
    if(screen.orientation.type.startsWith('landscape')){
      normalLandscape();
    }
    else if(screen.orientation.type.startsWith('portrait')){
      normalPortrait();
    }
  }
}

// change image values
function updateConfig(el){
  let value;
  switch(el.type){
    case 'checkbox':
      value = el.checked ? 1 : 0;
      break;
    case 'range':
    case 'select-one':
      value = el.value;
      break;
    case 'button':
    case 'submit':
      value = '1';
      break;
    default:
      return;
  }
  const baseHost = document.location.origin;
  const query = `${baseHost}/control?var=${el.id}&val=${value}`;

  fetch(query)
    .then(response=>{
      //console.log(`request to ${query} finished, status: ${response.status}`)
    })
}

// update menu values in html
function updateValue(el,value,updateRemote){
  updateRemote = updateRemote == null ? true : updateRemote;
  let initialValue;

  if(el.type === 'checkbox'){
    initialValue = el.checked;
    // make sure to explicity convet to a boolean
    value = !!value;
    el.checked = value;
  }
  else{
    initialValue = el.value
    el.value = value
  }
  if(updateRemote && initialValue !== value){
    updateConfig(el);
  }
}

// get camera initial values
function cameraInitialValues(){
  const baseHost = document.location.origin;
  fetch(`${baseHost}/status`)
  .then(response=>{
    return response.json();
  })
  .then(state=>{
    //console.log(state);
    document.querySelectorAll('.default-action').forEach(el=>{
      // update states of buttons, sliders and check buttons 
      updateValue(el,state[el.id],false);
      //update html values
      brightnessValue.textContent = brightnessInput.value;
      ledValue.textContent = ledIntensity.value;
      saturationValue.textContent = saturationInput.value;
      contrastValue.textContent = contrastInput.value;
      qualityValue.textContent = qualityInput.value;
    })
  })
}

// change external clock values
function setXclkValue(value){
  const baseHost = document.location.origin;
  const query = `${baseHost}/xclk?xclk=${value}`;
  fetch(query)
  .then(response =>{
    if (response.status !== 200){
      //console.log("Error["+response.status+"]:"+response.statusText);
    }
    else{
      //console.log(`request to ${query} finished, status: ${response.status}`)
      return response.text();
    }
  })
  .then(data=>{
    //console.log(data);
  })
  .catch(error=>{
    //console.log("Error[-1]:"+error);
  })
}
//----------------------------------------------------------------------

User interaction for mobile devices:
  1. Full-screen mode for landscape and portrait mode
  2. Pinch gesture (two-finger gesture) action to fit the image to the screen in full-screen landscape mode
  3. Return to normal mode with the mobile device's back button.
  4. Overlay menu for camera configuration
User interaction for desktop devices:
  1. Full-screen mode
  2. Overlay menu for camera configuration

Web page in portrait mode:
Web page in landscape mode:

Comments

Popular Post

ESP32 SPI Master - Slave

The SPI (Serial Peripheral Interface) acts as a synchronous data bus used to send data between microcontrollers and small peripherals that use separate lines for data and a clock that keeps both sides in perfect sync. In SPI only one side generates the clock signal ( SCK for serial clock). The side that generates the clock is called the Controller or the Master, and the other side is called the Peripheral or the Slave. There is always only one Controller , but there can be multiple Peripherals . Data sharing from the  Controller to the  Peripheral is sent on a data line called COPI (Controller Output Peripheral Input). If the Peripheral  needs to send a response back to the Controller data is sent on the line called CIPO (Controller Input Peripheral Output). The last line is called CS (Chip select) or SS (Slave select). This tells the peripheral that it should wake up and receive/send data and is also used when multip...

Prototype of a simple mobile robot

The first thing I made was a preliminary sketch of the most important parts with an overview of how I would like to look at the robot, it's a simple design but I need the robot to be modular, to make improvements and updates later. The general idea is to use it in different projects, by now, the first challenge is to build the structure and the second challenge is to write code to control the robot through Bluetooth and an Android app. To put the circuit PCB or just a protoboard over the top face of the robot I designed 4 sliders that rotate on their own axes. At the moment of designing the robot, I didn't know exactly the size of the PCB, so this simple mechanism will help me to adapt different sizes of circuits over the chassis of the robot. For more details, the  interactive 3D view  below allows you to isolate single or multiple components designed, so feel free to check the parts for a better understanding of how the robot works. To develop the st...

Analizing IMDB data (movie review) for sentiment analysis

This is the first neural networks project I made with preprocessed data from IMDB to identify sentiments (positive or negative) from a dataset of 50.000 (25.000 for training and 25.000 for testing). I tried to detail every step and decision I made while creating the model. In the end, the neural network model was able to classify with an accuracy of 81.1% or misclassify 11.9% of the data (around 3000 movie reviews). This is a high error margin considering that an acceptable error must be between 3% and 5%, but the model, in general, helped me and gave me clues to develop a new version. At the same time, I learned a slight introduction to Natural Language Processing, a topic new to me. 1. The dataset : IMDB (Internet Movie Database) ¶ References: ¶ Maas, A., Daly, R., Pham, P., Huang, D., Ng, A., & Potts, C. (2011). Learning Word Vectors for Sentiment Analysis. IMDB movie review sentiment classification dataset ...