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
- The code for this project is adapted to work with an ESP32-CAM AI-THINKER with an OV2460 sensor camera.
- ESP32-CAM requires a USB to UART TTL converter of 5v to upload code.
- 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:
- Full-screen mode for landscape and portrait mode
- Pinch gesture (two-finger gesture) action to fit the image to the screen in full-screen landscape mode
- Return to normal mode with the mobile device's back button.
- Overlay menu for camera configuration
User interaction for desktop devices:
- Full-screen mode
- Overlay menu for camera configuration
Comments
Post a Comment