autoconfig.corz.org text viewer..
[currently viewing: /public/scripts/ESP32/TL-CAM/TL-CAM.ino - raw]
/*
    TL-CAM

    aka. "Time-Lapse Cam", aka. "Tender Loving Cam". Okay, not that.

      "Your Friendly Neighbourhood Time-Lapse Camera Server"

    That'll do for now. More information here:

      https://corz.org/ESP32/time-lapse-camera-server/

    Enjoy!

    (c) me & the boys @ corz.org 2023

    NOTE: A lot of late-night coding sessions here. Expect bugs!

    See License.txt in the same directory as this sketch (Apache).


*/


String version = "1.0.2.0";   // "That hidden part we still polish"


// These libraries are built-in.
// NOTE: double quotes means "search in the local directory first, then the PATH".
// Angle braces means skip directly to searching the PATH.
#include <esp_camera.h>
#include <SD_MMC.h>
// For storing preferences..
#include <nvs_flash.h>
#include <Preferences.h>
// For Serial wake from light sleep..
#include <driver/uart.h>


// BE CAREFUL, turning this thing on..
#define FLASH_PIN      4

// Pin definition for CAMERA_MODEL_AI_THINKER ESP32-CAM
// Change these pin definition if you are using a different camera module.
// A list of common camera pin definitions is in the same directory as this sketch: "Camera Pins.txt"

#define PWDN_GPIO_NUM    32
#define RESET_GPIO_NUM   -1 // -1 == Not Used
#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

// These pin definitions work fine for the other standard sensors, e.g. OV5640, which have higher
// resolutions, but a common interface. I assume most sensors, would work right out-of-the-box, but
// I haven't tested those. OV2640, OV3660 and OV5640 (+ AF) I have tested and they work 100%.




/*
    Settings & Preferences..

                              */


/*
  Auto-Snap.

  The most basic switch for TL-CAM. When this is true, TL-CAM is capturing images at predetermined
  intervals and when set to false, it is not.

  The serial command to switch this is: ta

  This can be useful when setting up your camera and reviewing images, or using your TL-CAM as a
  manual camera or video streamer (you can capture images while video is streaming, no problem).

 */


bool autoSnap = true;



/*
  Snap Interval Time

  These are the default settings. You can change this on-the-fly via Serial console using:

    s<number>   to set the interval in seconds, or..
    m<number>   to set the interval in minutes.
    As well as h<int> for hours and d<int> for days.

  The interval time is saved to non-volatile storage, so it will be remembered after a reboot.
  Whichever convenient way you set it, TL-CAM will use milliseconds internally.

  You can also change this directly from the web settings panel.

  This setting is available from the web interface, saved to NVS and recalled on reboot.

*/

/*
  Set this to not-zero to use second intervals..
  A two second interval works fine on my AI-Thinker module at 5MP resolution.

  To know your limits, insert and SD Card and run this command: benchmark
                                                                              */

uint16_t delaySeconds = 0;

/*
  If you set delaySeconds, this is ignored.
                                            */

uint16_t delayMinutes = 60;

/*
  The timer begins /after/ the first image, which is always acquired immediately on boot-up, or
  rather, as soon as various hardware modules are initialised and ready-to-go. It's a lot less
  trouble to delete an image than it is to capture an image from the past. Actually, that is
  beyond our current technology. So, we capture from the get-go.
*/



/*
  Capture Size.

  By default, your capture size is set to the maximum your sensor can handle.
  You can change this here or with the Serial (and in the web interface, rsn) command:

    size 21

  "21" being FRAMESIZE_QSXGA, which will only work if you have a 5MP sensor.

  Here are the available sizes..

    Variable name        size         C enum

    UNSUPPORTED                        0    (this is used internally)

    FRAMESIZE_QQVGA,     160x120       1
    FRAMESIZE_QCIF,      176x144       2
    FRAMESIZE_HQVGA,     240x176       3
    FRAMESIZE_240X240,   240x240       4
    FRAMESIZE_QVGA,      320x240       5
    FRAMESIZE_CIF,       400x296       6
    FRAMESIZE_HVGA,      480x320       7
    FRAMESIZE_VGA,       640x480       8
    FRAMESIZE_SVGA,      800x600       9
    FRAMESIZE_XGA,       1024x768      10
    FRAMESIZE_HD,        1280x720      11
    FRAMESIZE_SXGA,      1280x1024     12
    FRAMESIZE_UXGA,      1600x1200     13
 // 3MP Sensors
    FRAMESIZE_FHD,        1920x1080    14
    FRAMESIZE_P_HD,       720x1280     15
    FRAMESIZE_P_3MP,      864x1536     16
    FRAMESIZE_QXGA,       2048x1536    17
 // 5MP Sensors
    FRAMESIZE_QHD,        2560x1440    18
    FRAMESIZE_WQXGA,      2560x1600    19
    FRAMESIZE_P_FHD,      1080x1920    20
    FRAMESIZE_QSXGA,      2560x1920    21

  HOWEVER, you may change the raw image size on-the-fly, particularly if you want a picture-in-
  picture live stream running while you work with TL-CAM, and you definitely don't want you captures
  to be at 320x240, or whatever your live stream happens to be.

  So, we have a separate setting for the capture size, which will always be applied prior to
  capturing an image, regardless of you current sensor setting.

                                           */

uint16_t captureSize = FRAMESIZE_QSXGA;



/*
  Image file name prefix.

  TL-CAM will construct the numeric part of the filename after this text here.
  For example, if you used..

    photo_

  .. TL-CAM would create file names like "photo_000027.jpg"  or  photo_01.50.53~03-05-23.jpg

  This String is removed before displaying file lists / thumbnails in the web controller.

  By default, it is blank.

 */

String fileNamePrefix = "";


/*
  If you need it, you can enable post-decimal-point precision with your file size displays.

  This applies to both serial and web displays, so yes, it will mean less /other/ information above
  your thumbnails, when enabled.

  Set this to false to ignore anything after the .                               i.e. 345kB
  Set this to true for an extra couple of digits after the decimal point.        i.e. 345.34kB

  If you use small thumbnails, I recommend you leave this set to false, to display more useful
  information. That's more "useful information", not "more useful" information! But that too!

  At any rate, if you need it, here it is.

 */

bool showDecimalSize = false;




/*
  Quick List as Default

  TL-CAM has mostly switched over to more clever ways to list files and calculate remaining image
  space and so on. This setting still exists so that, if required, we can force TL-CAM to produce a
  full text list of all image files with timestamps and sizes. In practice, this is rarely needed.

  As mentioned, listing a large amount of files is SLOW. This is because we need to open each file
  to retrieve its size and date information. For most operations, we can live without these data.

  If you regularly deal with large amounts of image files, you will likely want to leave this
  enabled. The time savings can be HUGE. Allow me to list my current image files both ways..

  command: ql

    Quick List completed in 0.26 seconds
    Total Files: 1018

  command: list

    Finished processing in 153.54 seconds
    Total files: 1018

  That's right, the regular method is almost 58,953% slower. lol

  I definitely recommend you leave this set to true. See below.

  Toggle this from the serial console with the command: tq

 */

bool quickListings = true;

/*
  NOTE: When this is set to true, you can still force a full listing (and accurate space
  calculation) in the serial/web console at any time with the "list" command.
*/



/*
  Initial File Interrogation Limit.

  At boot-up, TL-CAM checks the number of files on the card, space used, free space, etc..

  If quickListings is DISABLED, this could potentially take a *LONG* time. Or not, if you only have a
  small number of files.

  Here you can limit the initial file interrogation to THIS size. (MB)

  If your files take up more space than this, initial file interrogation will be skipped altogether.

  Unless you regularly deal with a small number of files (less than say, 300), I recommend you leave
  quickListings enabled.

  If quickListings is enabled, this is ignored.

                              */

uint16_t maxMBytesLimit = 300;



/*
  Display Details..

  In the web view, TL-CAM always uses quickListings mode automatically right up until the point
  where it starts to list the files you requested (e.g. ?start=300), then switches over to full mode
  for the duration of the file listing (so you can have dates and sizes, etc.). This gives you the
  best of both worlds, so to speak.

  This hybrid operation means a VASTLY reduced wait time to pages with ?start=<anything over 100>*.

  This happens all the time, by default, and is not configurable, because to disable quickListings
  for the files /before/ ?start= is time-wasting madness.

  HOWEVER, once listing has begun, you may wish to /remain/ in quickListings mode, which will reduce
  the time it takes for your web page to appear, more so as the start number increases. If you have
  only a few hundred images it will only be a couple seconds. At 1000 images, longer.

  You obviously get less file information displayed, but it will be FAST. If you don't care about
  those details and you want your page to display RIGHT NOW, set this to false.

  Toggle this from the command line with: dd

 */


bool displayDetails = true;

// NOTE: if quickListings = false, this is ignored, as details will always be shown.



// * The built-in FS library is straining my desire to use only built-in libraries!



/*
  LED FLASH

  This is super-bright and not recommended under any circumstances.

  Just Kidding. You can set the brightness here..

    0 - 255

  A brightness of 1-2 (night-day) is useful as an indicator of when image acquisition is occurring.

  A brightness of 255 can take down aeroplanes. I am not kidding now. The AI-Thinker Cam uses a 3030
  SMD, capable of over 180lM/W. A device commonly employed in street lighting and other industrial
  applications, and it is SUPER-BRIGHT. That is a technical term. It means; FFS! DON'T LOOK DIRECTLY
  INTO THE LED! Unless you like green spots in your vision and potentially permanent retinal damage.

    https://www.moon-leds.com/product-smd-led-chip-3030-3v-6v-160-180lm.html

  I Repeat: DO NOT look directly into the LED when brightness is > 128. You have been warned, twice.

  You will note from the product page that this SMD has a fairly poor CRI (Colour Rendering Index),
  so using flash will not get you accurate colours. This may not be important, but is good to know.

  This value can be changed on-the-fly (command: b<int>) and that change is saved to NVS and
  restored after a reboot. When you set a new value for the LED brightness, it will momentarily
  flash *at* that new brightness, so you can see how bright it is.

  Set to zero to disable the LED Flash altogether.

  This can be set on-the-fly in the web prefs panel or via the serial/web console.

  In the web preference interface, values over 127 are shown in red because HOLY $HIT! THAT IS
  BRIGHT! If you look directly into the LED at anything over 20 you will probably get green spots in
  your eyes. At 128 and over the damage may be more lasting. This is me warning you, AGAIN.

                         */

uint8_t ledBrightness = 2;



/*
  Print "Extended" information to the Serial console.

  This will enable notification of all Web requests, and other stuff.

  You can toggle this setting from a console with the command: e

  This setting is stored in NVS and remembered across reboots.

                 */

bool eXi = true;




/*
  Overwrite mode.

  Overwrites previous images, regardless. Useful when you want to leave TL-CAM in a looping
  situation where only the most /recent/ images matter.

  If you plan to leave your TL-CAM somewhere with a mind to capturing some event, where you will
  access your TL-CAM soon after, this is a good option. TL-CAM will keep writing images so long
  as it has power, replacing the oldest images with the newest.

  A 4GB card can store 10,000+ Hi-Res (5MP) images before maxing out. That's around a week's worth
  of 5MP images at 1 image per minute. At lower resolutions and slower intervals you could
  obviously go longer.

  In short, when this is set to true, if TL-CAM detects it is out of space, instead of aborting
  operations, it will carry on; overwriting all previous files as it goes.

  BE CAREFUL!

  This setting is saved to NVS.

  NOTE: This setting only works with sequentially numbered images, not timestamped images, as time
        cannot loop in the reality in which ESP32 devices exist.

 */

bool overWrite = false;

// NOTE: This has not been tested, but the code says it will work just fine.



/*
  Sleep between captures

  This will help greatly with power consumption on battery-powered installations.

  Toggle Auto-Sleep with this command: ts

  Note: If your snap delayTime is less than 3 seconds, Light Sleep requests will be ignored.
        If your snap delayTime is less than 20 seconds, Deep Sleep requests will be ignored.

  During Light Sleep Mode, power consumption is around 0.8mA, compared to the usual 160-260+mA.
  In Deep sleep, it is around 10uA. Quality batteries* will last a /long/ time.

  I recommend Samsung or Sony VTC6 18650 batteries. I'd love to recommend the Molicel P26A for its
  incredible capacity and endurance, but the construction isn't on a par with the two I previously
  mentioned and you need to be *extremely* careful with them to avoid buckling the contacts.

                          */

bool doSleep = false;



/*
  Instead of light sleep, enter Deep Sleep.

  This uses even less power, but you cannot wake up the device from sleep via the serial terminal.

  In fact, if you have auto-sleep enabled, and this is set to true, the only way you are getting
  back in is if you re-upload the sketch with the setting disabled. Or else, see the next setting.

  Note: If your snap delayTime is less than 20 seconds, Deep Sleep requests will be ignored.

  Waking from Deep Sleep is very much like a reboot, so everything starts afresh. Of course the big
  advantage is HUGE power saving. 10uA, baby!

                      */

bool deepSleep = false;


/*
  Deep Sleep Delay

  In Deep Sleep mode, TL-CAM will keep the serial connexion open for this number of seconds after
  capturing an image, before returning to deep sleep. As there is no easy way to wake an ESP32-CAM
  from Deep Sleep, this will enable you to enter some commands over the serial connexion, e.g..

    td

  Toggle Deep Sleep Mode!

 */

uint8_t deepSleepDelay = 10;


/*
  NOTE: Deep Sleep is handled slightly differently from Light Sleep.

  Light Sleep occurs directly after you take a picture, if auto-sleep is enabled. You can send any
  character over the serial connexion to wake up TL-CAM and continue where you left off. And it will
  stay awake until the next image is captured, or you manually put it back to sleep with the "sleep"
  command.

  To enable a delay for you to enter commands, deep sleep state is checked on every loop(). The
  upshot of all this is that if you toggled auto-sleep while deep sleep was enabled, TL-CAM would
  most likely go *immediately* to sleep; as a) a picture has been taken at some point and b) the
  delay time has passed.

  This also means you get 10 seconds to enter commands at boot-up, before TL-CAM enters Deep Sleep.

  Once awake, you can use the "web" command to fire up the remote control server.

 */



/*
  Add the file dimensions (e.g. "2560x1920") to the file names.

  Insert some string you wish to append to the file name. Somewhere in that string, use the "%x"
  token, which will be replaced, at capture time, with the dimensions of the image file captured.
  e.g..

    String addDimensions = "[%x]";    //    [2560x1920]
    String addDimensions = "_%x";     //    _2560x1920
    String addDimensions = "--%x";    //    --2560x1920

 */

String addDimensions = "";



/*

  Remote Control..

  This boolean gets flipped if your WiFi setup failed for some reason,
  to prevent us attempting to run web control features when there is no WiFi.

  This can also be toggled on-the-fly from the serial console with the 'tr' (Toggle Remote) command.

  NOTE: You can keep ONLINE defined (below), and set this to false to use internet time features but
  /not/ run an active web server, meaning serial connexions only.

                      */

bool remControl = true;




/*
  Local Timezone String

  https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html

  This string will setup the timezone with proper rules about when and for how long daylight saving
  time occurs. Google this exact string for a list of all the other zones.

  Set this to your /local/ timezone..
                                                                      */

const char *timeZone = "GMT+0BST-1,M3.5.0/01:00:00,M10.5.0/02:00:00";


/*
  Use Real Time

  With the help of your local NTP server, we can set and retrieve real file creation times..

  The "created" and "last modified" timestamps will now show the actual saved time, rather than
  some time in 1970 or whatever.

  In the absence on an internet connexion, we can fall-back to a user-set time:

    time 1689401015

  The big number being the UTC timestamp. See: https://www.unixtimestamp.com/

  Once TL-CAM has this, is will store and retrieve it automatically, updating with the *actual*
  time when an NTP server becomes available.

                        */

bool useRealTime = true;



/*
   Use date & time stamps for the file names..

                              */

bool timeStampFileNames = true;


/*
  Time/Date Format.

  If timeStampFileNames = true, you can choose the format of the date/time string user for naming
  files.

  Remember, timestamp information is overlaid onto your thumbnails (as it is stored as the file's
  creation time). Also, overwrite mode cannot work with timestamped file names (obviously!) so
  enable this only if you really need/want date & time-stamped file names. Edit to your needs.

  NOTE: In thumbnail view, this name will be truncated to fit the thumbnail width. Although less
  relevant in this view, you can still get to the full title, as well as the file's numerical index
  in the filesystem, by hovering your pointer over the thumbnail.

  NOTE: putting spaces in here, while possible, would be stupid, as it will break things like
  HTML id's, which we use for delete events in the slideshow view. As short as possible, is the way.

  As mentioned, in thumbnail mode, the name is truncated to fit into the thumbnail view. Consider
  this. And see the next preference.

  With the default string, you will still see hours and minutes even with 50px thumbnails.

  The file extension is set elsewhere.

*/

const char *dateTimeString = "%H.%M.%S~%d-%m-%y"// 01.50.53~03-05-23

/*
  You can include any literal characters which are safe for file names on a FAT32 file system.
  The following tokens are converted to their current time/date value..

  %a Abbreviated weekday name
  %A Full weekday name
  %b Abbreviated month name
  %B Full month name
  %c Complete date and time representation for your locale
  %d Day of month as a decimal number (01-31)
  %H Hour in 24-hour format (00-23)
  %I Hour in 12-hour format (01-12)
  %j Day of year as decimal number (001-366)
  %m Month as decimal number (01-12)
  %M Minute as decimal number (00-59)
  %p A.M./P.M. indicator for 12-hour clock (in current locale)
  %S Second as decimal number (00-59)
  %U Week of year as decimal number, Sunday as first day of week (00-51)
  %w Weekday as decimal number (0-6; Sunday is 0)
  %W Week of year as decimal number, Monday as first day of week (00-51)
  %x Date representation for current locale
  %X Time representation for current locale
  %y Year without century, as decimal number (00-99)
  %Y Year with century, as decimal number (e.g. 2023)
  %z %Z Time-zone name or abbreviation, (blank if time zone is unknown)
  %% Literal Percent sign

  For more details (and locale tokens, etc.) see: https://cplusplus.com/reference/ctime/strftime/

  Examples:

    "%A, %B %d %Y @ %H.%M.%S"   =>    Tuesday, May 02 2023 @ 17.15.30

    "%d-%m-%Y@%H.%M.%S"         =>    02-05-2023@18.13.00   (final name: photo_02-05-2023@18.13.00.jpg)

    "%H.%M.%S~%d-%m-%y"         =>    01.50.53~03-05-23

   NOTE: The ESP32's built-in strftime does not support # - _ etc. modifiers, so we're stuck
   with zero padding unless I ever get around to caring enough to code up an alternative.

*/




/*
  Reserve memory for thumbnail / file list creation.

  This should be good up to 200+ thumbnails. If you need more, increase this number accordingly.

  Each thumbnail uses around 700 - 800 bytes. Then add a wee bit extra.

 */

uint32_t memReserve = 175000;






/*
  Stream Recording File Name

  This is the name the downloaded MJPEG file will have in your downloads directory.

  You can start and stop recording a live stream at the current streaming resolution, by using (')
  (apostrophe) in the web interface, or by hitting /switch with a web-capable device.

  Once you have a recording, you can download it from the prefs panel (,).

  It's probably wise to keep the .mjpeg extension.

  Unlike most any other MJPEG file you will come across, this will happily play in VLC (desktop) and
  ffplay (and so also ffmpeg, for converting). I'll test other media players when I get a chance.

  You can initiate a recording from the console using:

    record

  And stop it with:

    stop


 */

String streamFileName = "TL-CAM.mjpeg";

/*
  About the downloaded video stream..

  REMEMBER: This is RAW output and will very likely be speeded-up when you play it back in VLC.

  MJPEG has no built-in mechanism for frame-rate limiting. You could use TL-CAM's built-in frame-
  rate limiting mechanism, but you would simply be skipping frames, which is probably not what you
  want. By default we record ALL frames at whatever rate they arrive.

  In most any player, playback will default to 25 fps, and unless you are recording at a really low
  resolution, you will not achieve this kind of frame rate from your ESP32-CAM.

  If you want a video file with the "correct" frame-rate (so it plays back at the same speed it was
  recorded) you have two options:

    1: record the stream with VLC (or whatever) at /stream. This might not have the fidelity of the
       raw stream, but it's a low-effort way to get what you want.

    2: Use FFmpeg (there may be other tools capable of this, I dunno) to convert the video file's
        frame-rate. A simple command like this will slow it down to half speed:

        ffmpeg -y -f mjpeg -i /home/Downloads/TL-CAM.mjpeg -an -filter:v "setpts=2*PTS" -an ~/TMP/out.mp4

        Replace the paths and what-not. You will likely want to mess with codecs and quality
        and bitrates and such, but that's the bare-bones and would work as-is. There will be
        cleverer ways, no doubt. Answers on a postcard. Or just email.

  NOTE: The live stream preview does NOT need to be open to record a live stream. The pulsing red
        recording dot will display in the bottom-right of main interface.

*/







// Comment out the following directive to completely disable WiFi, Time and Web Control features.
#define ONLINE

//  If the above directive is commented-out, ALL the following prefs will be ignored..


/*

  WiFi + Remote Control Settings.


  When "ONLINE" is not defined (immediately above), the compiler will completely ignore all wifi/
  web/time code/associated libraries/etc., so your binary will be smaller. This might be useful in
  some situations; maybe for super-quick compiles when testing non-remote code.
                                                                                                */


#if defined ONLINE

/*
  WiFi Connect time-out (in seconds, 1-255).
  This should never happen.
                    */

uint8_t timeOut = 10;


/*
  If you have enabled online features, enter your WiFi network credentials here..

                                  */

const char *ssid      = "YOUR-SSID";
const char *password  = "YOUR-WIFI-PASSWORD";


/*
  Custom Host Name

  If you want to set a custom host name for your device, put it here.
  If this is empty TL-CAM won't attempt to setup a host name.

                                  */

const char *SGhostName = "espcam";

/* You will probably want to use this same host name on your router's (fixed IP) DHCP lease. */



/*
  Soft Access Point

  TL-CAM has a built-in Wifi Access Point. (Gateway IP: 192.168.4.1)

  Set the SSID for the Access Point here..

  (this is what will appear in the list of available WiFi networks on your phone / PC / whatever)

  Make this blank to disable the Soft AP.
                                         */

String softAPSSID = "TL-CAM-AP";


/*
  Password for the AP.

  Leave this blank to have an open network (i.e. no security). This is /not/ recommended, as
  anyone could login and see your images, change settings, etc..

                                    */

String softAPPass = "JustOpenUpFool";


/*
  AP Only mode

  In this mode TL-CAM will not attempt to connect to any network, but will
  establish its /own/ network only. You can connect via its WiFi Access Point (see above).

                    */

bool onlyAP = false;



/*
  NTP (Time) Server address

  Use your local time server to set the actual time. Now we can use proper file creation times and
  timestamp our files, if required.

  This won't work in AP-Only mode. You need to be connected to "da internet".

  Set this to the address of your nearest NTP server..

const char* ntpServer = "1.uk.pool.ntp.org";
const char* ntpServer = "pool.ntp.org";

  When testing / hammering, you can use one of Google's time servers..

                                          */

const char *ntpServer = "time1.google.com";




/*
 Real Time is REQUIRED..

 If you set this to true, on NTP failure TL-CAM will keep trying the server until a correct time
 has been established, /before/ beginning image acquisition. To be clear, unless the correct time
 has been established, NO IMAGES WILL BE ACQUIRED.

 You can set a maximum number of retries, below.

                           */

bool realTimeRequired = true;


/*

  Fall-back to stored times.

  In the event of NTP failure, TL-CAM can fall-back to using stored times, which will have either
  been manually set by you:

    time <UTC>

  or else supplied by the most recent contact with a time server. On reboot, TL-CAM stores the
  current UTC to be picked up again at boot-up. You can setup your cam where there is internet
  access and deploy where there isn't, yet still have accurate-ish timestamps.

  If this is set to false, TL-CAM will instead either:

    a) revert to using numerical filenames (realTimeRequired == false)

  or

    b) reboot and try again (realTimeRequired == true). Potentially for ever.

                         */

bool useStoredTime = true;



/*
  NTP Retries.

  When realTimeRequired == true, TL-CAM will retry the NTP server THIS many times before giving up
  and rebooting / falling-back to stored time.

  In my experience; on failure, the 2nd attempt will usually work just fine.

                          */

uint8_t maxNTPRetries = 5;




/*
  Truncation direction.

  If you have you files named, for example: 2023-05-02@22.13.04; when in thumbnail mode, you may
  want to truncate from the /start/ of the file, as opposed to the end. If so, set this to true.

  NOTE: There is no truncation in List View mode.

 */

bool truncateReverse = false;




/*

  Web Page Settings..

  We don't store any of the web view settings to NVS as everything is GET parameters and lives in
  the URL. You can bookmark, script, whatever to get the exact same setup back again at any time.

  It also means you can have more than one tab open in your browser with different views.

*/



/*

  Number of image links / thumbnails to list on the main page.

  You can use ?start=<number> to start at a different number, like the pagination buttons do.

  This can be set dynamically from the web interface, so this here is the /minimum/ value that will
  appear in your drop-down list of values.

  The maximum is 200. If you need more than this, you will likely want to see memReserve, below.

  Also, congratulation on having a screen big enough for over 200 thumbnails!

                            */

uint16_t imagesPerPage = 20;


/*
  You can choose the step value used for creating the drop-down menu for images-per-page, and
  thereby tune it to your interface.

  If set to 10, your menu choices would be 20, 30, 40, etc.. up to the maximum (200).

  The first menu entry is always whatever you set for imagesPerPage, above, regardless of what your
  /current/ images-per-page setting happens to be.
                             */

uint16_t perPageMenuStep = 10;




/*
  Thumbnail size.

  Set the width of thumbnails. In pixels.

  Okay, it's actually the other way around. In fact we set the *height* to 3/4 of the user thumb
  width (so the thumbnails flow properly). Regular 4:3 images will scale perfectly - other ratios
  will scale to fit at *whatever* width.

  NOTE: The thumbnails you see are the full images, reduced by your browser, which these days
  happens in a nice way, with Lanczos scaling and what-not.

  Upside: when you click on them, being already cached, they will now load instantly in a new tab.
  Same story for pop-up previews, which use the same source (src=) image. Ditto for SlideShow.

  There is a drop-down menu in the web interface where you can select your desired thumbnail size.

  A fair bit of work has gone into enabling you to have *tiny* thumbnails, lots of them; the
  textual information resizing with the thumbnail, all the way down to 50 pixels.

  You should be able to fit twenty 50px thumbnails per row, even on a 1366x768 screen.

  HINT: If you have only so much thumbnails-per-page to fill less than half your browser viewport
  height, TL-CAM switches automatically to "Gallery Mode". Enjoy.

                         */

uint16_t thumbWidth = 125;

/* This can be set dynamically from the web interface or address bar/URL/script/cURL/whatever (&width=150) */



/*
  What do you call "Images"?

  These two Strings are used to create the main title at the top of the web page.
  If you use "Images" and "image", the title would be something like..

    Images 1-100 (87 images)

  But you might prefer to call them something else (Photos, Captures, whatever), so I put these
  Strings up here where they are easy to get to.

  Sure, we could just perform operations on one single String; but hey! You might want to use two
  /different/ Strings..


 */

String imgTitleName = "images"// Plural. I like lower case here but it's your call.
String imgTallyName = "image";  // (The one in parentheses: Singular; the "s" is added automatically, as required)


/*
  Please wait.

  Similarly, you can set the "please wait.. / loading.. " message.

  This seemingly trivial feature provides a layer of debugging in the UX for folk messing with the
  JavaScript (me). If you (I) messed up somewhere, the page will sit there, "loading..".

                               */

String loadingMsg = "loading..";



/*
  Rotating "loading" icon.

  I noticed that when I am real close to my AI Thinker ESP32-CAM, I can actually *hear* the SD
  card operations, which turn out to be useful data; e.g. when in list view with pop-up previews
  enabled, you can hear when the pre-loaded images have finished loading.

  It's a feature I miss when I'm *not* near the cam, so I made a visual indicator that performs the
  exact same function. The Reload button will slowly rotate its symbol until /all/ the images are
  loaded.

  If that sort of thing annoys you, disable it here.
  This can also be toggled from the prefs panel.

                          */

bool loadingIcon = true;



/*
  Looping PoP Previews..

  When the PoP-Up Preview is in sticky mode, you can use arrow keys to navigate your images.

  (You can also hit "Delete" to delete the current image)

  When you reach the end, TL-CAM will thoughtfully pulse the link a couple times to let you know.

  Or else you can just have it loop right round the other end.

  This can be toggled from the prefs panel.

  It defaults to false so you can see the cool pulsing effect before switching to loop mode.

                  */

bool loopPoP = false;



/*
  How long (in milliseconds) to display messages in the web interface.

  1500 - 2500 works well (1.5s - 2.5s) depending on how fast you read.

  NOTE: SD Space and memory information messages will display for 3x this time.

 */

uint16_t webMessageTime = 2000;




/*
  You can put extra space between the [information] elements in the List View.
  Or else maximise the space to the erm, max, for /slightly/ larger PoP-Up Previews.

  You can adjust the size of the spacing in the .pad-left CSS class. (search:pad-left)
  Currently it is 0.2em.

  NOTE: This can be set dynamically from the prefs panel and is stored in a browser cookie.

                               */

bool spacesInListView = true;


/*
  Skicky PoP..


  On mouse-enabled setups, when you hover over a thumbnail, the PoP-Up Previews pops up and when you
  move your mouse away from the thumbnail, it vanishes again.

  On touch devices, when you swipe a thumbnail, the PoP-Up preview pops up and "lingers".

  If you want this sticky behaviour in your desktop browser, set this to true..

 */


bool stickyPoP = false;



/*
  Rounded Corners on thumbnails, thumbnail boxes, pop-up previews and delete buttons?

  TL-CAM can display these things with tastefully rounded corners, or not.
                          */

bool roundedCorners = true;
/*
   If you want sharp corners on your control buttons, edit the CSS: .c-button class.
*/




/*
  Auto-Play SlideShow Timer.

  How long before we switch to the next image?
  In seconds.

  NOTE: This can be set dynamically from the web prefs panel and is saved to a browser cookie, so
  you can have different times on different setups/browsers.

  So this is the default setting, for when the user hasn't changed it.

 */

uint8_t slideTime = 3;



/*
  In a slideshow, I personally like to have the DOWN arrow take me to the NEXT image and the UP
  arrow take me to the PREVIOUS image, the way a list works.

  If you consider that back-to-front, set this to true..

 */


bool upDownRev = false;




/*
  Image Caching

  Thumbnails/Images are cached by your browser to prevent stress on you and your ESP32 module if you
  are going backwards and forwards or refreshing in the thumbnail view, watching slideshows, etc...

  If you want the images to load completely afresh, you can use Ctrl+F5 (or your OS's equivalent).

  If you do a lot of deleting, or enjoy a slideshow, I recommend you leave this set to true, at
  least for a few minutes (see next pref).

  To disable caching altogether, so images *always* load afresh (which for 2MP and 3MP images at
  least, is actually pretty fast), set this to false.

  NOTE: This can be set from the web prefs panel and is saved to a browser cookie.

                         */

bool cacheImages = true;


/*
  Cache Time

  How long to cache images in your browser, in minutes.

  After this time, images will *always* re-load afresh.

  NOTE:!: If you enable caching and have the dev tools open in Chrome (F12), ensure you haven't
          checked the "Disable cache (while DevTools is open)" checkbox; which I believe is the
          default setting - click the gear icon to check / change this.

          There's three minutes I won't get back!

  NOTE: This can be set from the web preferences and is saved to a browser cookie.

  NOTE: The caching header is sent *with* the image, so if you set the cache time to 30m and load
        an image and then set the cache time to 60m, the image will be considered stale after 30m,
        not 60m. If you refresh the image, NOW it will be cached for 60m.

                        */

uint16_t cacheTime = 30;
                        /*
  When caching is enabled, you can hit REFRESH (F5, probably) and get your new thumbnails without
  forcing your ESP32 module to produce the entire page afresh (which means re-loading (streaming)
  every single image on the page). If you tend to go backwards and forwards a lot, or are deleting
  images, or use the SlideShow feature, caching is best. For my usage scenarios, caching is best.
*/




/*
  Dark Mode

  You can switch between light and dark modes from the prefs panel.

  When you switch modes, TL-CAM sets a cookie in your browser and reloads the page.

  This here is the default setting for a fresh install or new web browser with no stored cookie.

  darkMode can be one of three settings: LIGHT, DARK, and AUTO.

                      */

bool darkMode = false;



/*
  Automatic Dark Mode.

  TL-CAM can switch the interface over automatically, depending on the time of day.
                    */

bool autoDark = true;


/*
  For Automatic Dark Mode..

  Set the hour in which day and night begin (in 24h notation).

                    */

uint8_t dayBegins = 6;
uint8_t nightBegins = 20;




/*
  Background "color" for your web interface.

  There are two of these. One for light and the other for dark.

  This color applies to all backgrounds; page, buttons and controls, borders, and so on, and the
  darkMode switch (above) switches between the two.

  White is "#fff"     (CSS short-form rgb hex for: 255, 255, 255)

  This is a nice light color: "#ddfda9;"

                                    */

// Light
String lightBGColor = "#fff";

// And Dark
String darkBGColor = "#1b1f15";




/*
  A note about thumbnail ordering; we don't.

  It's not feasible. The order you get thumbnails is the order the SD file system spits them out.

  If you insert a blank SD and take lots of pictures, the ordering will be /perfectly/ numerical,
  but if you delete any images from the start/middle of the set, and then take more pictures, the
  ordering will be decided by the machine-elves, or something. Or more likely by the first available
  allocation blocks.

  At any rate, if you need to see your files in some particular order, use a PC. Or let TL-CAM work
  without interruption.

  The only time this might be important is if you use the "wipe" command with a numerical index,
  e.g..

    wipe 50

  Where all file from 50-onwards will be deleted. BUT this does not mean "photo_00050.jpg" onwards,
  but "the file that got spat out in 50th position, onwards". Use the:

    list / l

  command to see the current file order. The numerical index of each file is printed out alongside
  the file information. This index is also posted in each thumbnail's pop-up title.

*/



/*
  SNAP!   (yeah, but how?)

  TL-CAM offers /two/ commands to snap an image on-demand;

    !     (exclamation point) Snap an image right now and RESET the snap interval timer to NOW.

    .     (regular point/full stop) Snap an image right now and DO NOT RESET the timer.

  Which variant would you like the /web interface/ to use?

  NOTE: This can be set from the web preferences and is saved to NVS.

*/

bool webSnapResetsTimer = false;




/*

  Twin Streaming.

  aka. Simultaneous Streaming Preview AND Recording.

  By default, if you start recording the stream when the live stream preview is open, the live view
  pauses until recording completes. This makes for optimal frame-rates. But of course, you can't see
  the live stream as it happens.

  If you really need this, and don't mind the frame-rate hit (maybe 5-8%, not too bad), you can
  enable simultaneous streaming and recording right here.

  This can also be set from the prefs panel and is remembered in a cookie.

                            */

bool streamANDRecord = true;

/*
  NOTE: If you want to watch and record simultaneously, start the live stream /first/, then start
        recording. It won't work the other way around.
 */





/*
  Zoomed Mouse Move Acceleration

  When you click a stream to switch to full size, one of two things happens. If the stream size is
  smaller than the viewport, you can pick it up and move it around, rotate it (R), etc..

  If the stream size is *bigger* than the viewport, it will fill the viewport and zoom-in to
  wherever you clicked. You can then move around the image by either zooming in and out to wherever
  you need, wheeling around (shift-wheel should move you sideways) or my favourite..

  Drag the image around.

  I like it because the mouse motion is accelerated and you can whiz around a 5MP image like your
  pointer has a rocket-pack, or not. It's up to you.

  Any whole number between 1 and 100 is fine; the default being 10, which should enable you to get
  to every point of a 5MP image from clicking directly in the centre and dragging.

  Higher numbers increase acceleration. The farther your mouse travels, the faster it gets.

  1 enables *extremely* small adjustments to the position, yet still enables a /reasonable/ speed
  for moving around (click-click would be faster). 100 is just silly.

  Technically, the upper limit here is 127 (uint8_t), but that's crazy; or else the basis of a fun
  new party game entitled, "Goan! Centre My Nose!".

  There is a slider control for this in the prefs pane.

  Settings are stored in a cookie so you can keep different setups for different systems/browsers.

                          */

uint8_t zmAcelleration = 10;





/*
   Hacks.
          */


/*
  On Chrome, the Streaming SlideShow will appear to run one number behind what is reported to the
  console, due to a bug they seem unwilling to fix, as it look like they want to end support for
  multipart streams.

  This is of course lazy and stupid on the part of Chrome devs, but there you have it. Firefox wins.

  If you are watching your console during a Streaming SlideShow in Chrome, this will be confusing,
  and so you can enable this hack.

  You will still need to wait for the /second/ image to load* before you /see/ the first, and so on,
  but at least the numbers in the console will match what you see.

  * If that is going to be a long time, you can right away send the command: next

                        */

bool chromeHack = false;




// End ONLINE Settings
#endif


/*
   End Prefs
              */




/*
   Basic internal variables setup
                                    */


// Non-blocking delay for snap timer..
uint64_t lastSnapTime = millis();
uint64_t delayTime;

// Let's save a few calculations down the line..
const uint64_t MINUTE_MILLIS = 60000;
const uint64_t HOUR_MILLIS = 3600000;
const uint64_t DAY_MILLIS = 86400000;
// I love how these all line up!

// For sleep..
const uint64_t SECOND_MICROS = 1000000;

// For Automatic Dark Mode..
bool nightTime = false;

// Wear-levelling bytes used..
uint64_t levellingBytes;

// Calculated from your current collection of image files..
uint64_t averageSize;

// Also used offline..
uint32_t mostRecentPic = 0;

// The attached sensor, as a String, e.g. "OV5660".
String sensorType;
bool supported;

// Time..
uint64_t storedTime;
struct tm timeinfo;

bool doRecording = false, amStreaming = false, amRecording = false;

// Remote Control..
#if defined ONLINE

// WiFi/Web libraries..
#include <WiFi.h>
#include <WebServer.h>

// For the multipart live stream. Some string to use as a delimiter for the JPEG frames.
#define PART_BOUNDARY "1z2z3z4z5z6z7z6z5z4z3z2z1"

// The web server..
WebServer server(80);

// We'll re-use these..
const String _HTML_ = "text/html";                 // UTF-8 is the default for HTML5.
const String _TEXT_ = "text/plain; charset=UTF-8"// For text/plain, you need to specify if you want UTF-8 characters to work.
const String noMoreSlides = " No more slides!";

// NOTE: The text/plain output is just that; there is no HTML, no styles. In theory, this means you
// get plain text the way /you/ like it, in your preferred browser font and, at least with modern
// versions of some browsers, automatic day/night colours, just like TL-CAM itself.

// NTP..
uint64_t updateTime;
uint8_t retryCount = 1;

bool darkModeINIT = darkMode;
bool autoDarkINIT = autoDark;
bool amDark = false;
uint8_t slideTimeINIT = slideTime;
bool stickyPoPINIT = stickyPoP;
bool loadingIconINIT = loadingIcon;
bool loopPoPINIT = loopPoP;
bool streamANDRecordINIT = streamANDRecord;
uint8_t zmAcellerationINIT = zmAcelleration;
bool spacesInListViewINIT = spacesInListView;
bool cacheImagesINIT = cacheImages;
uint16_t cacheTimeINIT = cacheTime;

bool silentStream = false;
bool thumbView = false, doPoP = false;
uint32_t displayFrom = 1;

// This is the highest number which will appear in the images-per-page drop-down menu
// You could increase this, but do first edit memReserve (above).
uint8_t perPageMax = 200;

// Final background color
String bgColor;

// Remember initial user settings..
// (no web settings are saved to NVS, as you have GET parameters you can store)
uint16_t INITimagesPerPage;
uint16_t INITthumbWidth;

// // The first time a new client loads the web console page they get the full list of command.
// // We use this string to store "known" clients, so we don't repeat that on subsequent requests.
// // This is reset on reboot.
// String knownClients;

// For live stream and streaming slideshow..
const char *headerData = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\n";
const char *contentType = "Content-Type: multipart/x-mixed-replace; boundary=" PART_BOUNDARY "\r\n";
const char *boundaryMark = "\r\n--" PART_BOUNDARY "\r\n";
const uint8_t bmLen = strlen(boundaryMark);

const char *frameType =  "Content-Type: image/jpeg\r\nContent-Length: ";
const uint8_t ctLen = strlen(frameType);

// Streaming SlideShow Commands..
bool sShowPlaying = false;
bool sssPause = false;
int64_t sssSkip = 0;
String currentSlide;


// #else
// bool useRealTime = false;
#endif


// Initialize NVS Preferences Instance..
Preferences prefs;

// xCommand for command overrides.
String xCommand, mostRecentCapture;

// This string is set with the output from commands. It is the console "output", like you would get
// in a serial console. In the Web Console, we use AJAX to fetch it directly after a command is sent.
String LastMessage = "foo";

// Tallies and Counts for file listing/viewing..
uint32_t totalFileCount;
uint32_t saveNumber;

// Any of the JPEG extensions should work fine here..
String fileEXT = ".jpg", imgDimensions;

// true when SD is ejected (properly, with "eject" command)
bool ejected = false;

// If an SD Card write fails, or some other fatal error, we immediately stop taking photos..
bool abortUserNotified = false;
String ABORT = "0"// The why. Reported to your console. Also acts as boolean for the ABORT state.

// We'll re-use this..
uint32_t _1MB_ = 1048576;  // 1 real MB, in bytes (1024 * 1024)

// SD Benchmark..
uint32_t SDTestSize = _1MB_;

// LED Brightness..
const uint8_t ledChannel = 1;     // This is a GOOD channel!
const uint16_t ledFreq = 8000;    // PWM frequency - keep it high-ish for picture-taking.
const uint8_t ledResolution = 8;  // 8-bit, so we can use 0-255 for brightness.

// Image Settings..
int16_t brightness, contrast, saturation, special_effect, awb, awb_gain, wb_mode, dcw;
int16_t aec, aec2, ae_level, aec_value, agc, agc_gain, gainceiling, min_frame_time;
int16_t raw_gma, bpc, wpc, lenc, vflip, hmirror, denoise, sharpness, colorbar, framesize;
float_t max_fps;
// We'll need / want to set these before reading user prefs..
int16_t xclk=20, quality=10;

// Unless otherwise stated, it's VGA:  size         enum
int16_t maxSize = FRAMESIZE_VGA;    // 640x480      8

// Setup limits for different sensors.
// These are the "standard" limits, e.g. OV2640
int16_t minBrightness = -2, maxBrightness = 2;
int16_t minContrast = -2, maxContrast = 2;
int16_t minSaturation = -2, maxSaturation = 2;
int16_t minAELevel = -2, maxAELevel = 2;
int16_t minSharpness = -2, maxSharpness = 2;
int16_t minDenoise = 0, maxDenoise = 1;
int16_t minGC = 0, maxGC = 6;

String frameSizes[] = { "UNSUPPORTED""QQVGA""QCIF""HQVGA""240X240""QVGA""CIF""HVGA""VGA""SVGA""XGA""HD""SXGA""UXGA""FHD""P_HD""P_3MP""QXGA""QHD""WQXGA""P_FHD""QSXGA" };

uint64_t avgSizes[] = { 1, 2007, 2652, 4413, 6021, 8028, 12380, 16056, 32112, 50176, 82216, 96384, 137031, 200724, 216780, 96384, 138741, 328867, 385392, 428216, 216780, 513853 };


/*
    96x96        0    Ave kB    Ave B       96x96 not supported. We use 0 internally.

    160x120      1    1.96      2007
    176x144      2    2.59      2652
    240x176      3    4.31      4413
    240x240      4    5.88      6021
    320x240      5    7.84      8028
    400x296      6    12.09     12380
    480x320      7    15.68     16056
    640x480      8    31.36     32112
    800x600      9    49        50176
    1024x768     10   80.29     82216
    1280x720     11   94.09     96384
    1280x1024    12   133.82    137031
    1600x1200    13   196.02    200724
    1920x1080    14   211.7     216780
    720x1280     15   94.09     96384
    864x1536     16   135.49    138741
    2048x1536    17   321.16    328867
    2560x1440    18   376.36    385392
    2560x1600    19   418.18    428216
    1080x1920    20   211.7     216780
    2560x1920    21   501.81    513853
*/



/*
  Gather up Preferences.

  Get and assign settings from Non-Volatile Storage..

  USED: a, b, d (delayTime), e, o, p, q, r, s, t, u, w, z

  Doubles as help for the sensor settings.

                                    */

String gatherPrefs(bool init=false, bool final=true) {

  char buffer[420] = {'\0'};

  // We always print this out (gathered elsewhere)..
  sprintf(buffer, " Current Settings:\n\n");

  sprintf(buffer + strlen(buffer), " Snap Interval Time:\t%s\n", convertSeconds(delayTime/1000).c_str());

  if (init) eXi = prefs.getBool("e", eXi);
  sprintf(buffer + strlen(buffer), " Extended Information:\t%s\n", (eXi ? "enabled" : "disabled"));

  if (init) remControl = prefs.getBool("r", remControl);
  sprintf(buffer + strlen(buffer), " Remote Control:\t%s\n", (remControl ? "enabled" : "disabled"));

  if (init) doSleep = prefs.getBool("z", doSleep);
  sprintf(buffer + strlen(buffer), " Auto-Sleep:     \t%s\n", (doSleep ? "enabled" : "disabled"));

  if (init) autoSnap = prefs.getBool("a", autoSnap);
  sprintf(buffer + strlen(buffer), " Auto-Snap:     \t%s\n", (autoSnap ? "enabled" : "disabled"));

  if (init) deepSleep = prefs.getBool("s", deepSleep);
  sprintf(buffer + strlen(buffer), " Deep Sleep:     \t%s\n", (deepSleep ? "enabled" : "disabled"));

  if (init) ledBrightness = prefs.getUChar("b", ledBrightness);
  sprintf(buffer + strlen(buffer), " LED Brightness:\t%i\n", ledBrightness);

  if (init) overWrite = prefs.getBool("o", overWrite);
  sprintf(buffer + strlen(buffer), " Overwrite Mode:\t%s\n", (overWrite ? "enabled" : "disabled"));

  if (init) quickListings = prefs.getBool("q", quickListings);
  sprintf(buffer + strlen(buffer), " Quick Listing Mode:\t%s\n", (quickListings ? "enabled" : "disabled"));

  if (init) timeStampFileNames = prefs.getBool("t", timeStampFileNames);
  sprintf(buffer + strlen(buffer), " Timestamp file names:\t%s\n", (timeStampFileNames ? "enabled" : "disabled"));

#if defined ONLINE
  if (init) webSnapResetsTimer = prefs.getBool("w", webSnapResetsTimer);
  sprintf(buffer + strlen(buffer), " Web snap resets timer:\t%s\n", (webSnapResetsTimer ? "true" : "false"));

  if (init) displayDetails = prefs.getBool("p", displayDetails);
  sprintf(buffer + strlen(buffer), " Display Details:\t%s\n", (displayDetails ? "enabled" : "disabled"));
#endif

  if (final) sprintf(buffer + strlen(buffer), " %s\n", getFreeEntries().c_str());
  return (String)buffer;

}




String gatherSensorPrefs(bool init=false) {

  static char buffer[3086] = {'\0'};
  char *c = buffer;
  c += sprintf(c, "\n Sensor Settings:\n\n");
  
  /*
    Sensor Settings..

    It might seem simpler to use the two built-in NVS functions for this (which TL-CAM uses for the
    quick backup facility), and it may be, but TL-CAM prefers to keep its own set of all sensor
    variables.

    NOTE: The quick backup and restore facility does NOT save and restore the following settings:

          capture size
          clock frequency
          frame-limit

    */


  // This is a separate setting, so you can have small live stream and full-size captures..
  if (init) captureSize = prefs.getShort("size", captureSize);
  c += sprintf(c, " %s size: %i [%s]  \tCapture Frame Size (2MP:0-13, 3MP:0-17, 5MP:0-21): \n", prefs.isKey("size") ? "*" : " ", captureSize, frameSizes[captureSize].c_str());
  
  if (init) ae_level = prefs.getShort("ae_level", 0);
  c += sprintf(c, " %s ae_level: %i      \tAuto Exposure Level (-2 to 2, OV3660/OV5640: -5 to 5)\n", prefs.isKey("ae_level") ? "*" : " ",ae_level);
  
  if (init) aec2 = prefs.getShort("aec2", 1);
  c += sprintf(c, " %s aec2: %i          \tAutomatic Exposure Correction (0/1): \n", prefs.isKey("aec2") ? "*" : " ",aec2);
  
  if (init) aec = prefs.getShort("aec", 1);
  c += sprintf(c, " %s aec: %i           \tExposure Control (0/1)\n", prefs.isKey("aec") ? "*" : " ",aec);

  if (init) aec_value = prefs.getShort("aec_value", 204);
  c += sprintf(c, " %s aec_value: %i  \tAuto Exposure Amount (0-1200, OV3660: 0-1536, OV5640: 0-1920)\n", prefs.isKey("aec_value") ? "*" : " ",aec_value);
  
  if (init) agc = prefs.getShort("agc", 1);
  c += sprintf(c, " %s agc: %i           \tExposure Gain Control (0/1)\n", prefs.isKey("agc") ? "*" : " ",agc);
  
  if (init) agc_gain = prefs.getShort("agc_gain", 0);
  c += sprintf(c, " %s agc_gain: %i     \tAGC Gain Level (when agc=0) 0-30, OV3660/OV5640: 0-64\n", prefs.isKey("agc_gain") ? "*" : " ",agc_gain);

  if (init) awb = prefs.getShort("awb", 1);
  c += sprintf(c, " %s awb: %i           \tAutomatic White Balance (0/1)\n", prefs.isKey("awb") ? "*" : " ",awb);

  if (init) awb_gain = prefs.getShort("awb_gain", 1);
  c += sprintf(c, " %s awb_gain: %i      \tAutomatic White Balance Gain (0/1)\n", prefs.isKey("awb_gain") ? "*" : " ",awb_gain);

  if (init) bpc = prefs.getShort("bpc", 0);
  c += sprintf(c, " %s bpc: %i           \tBlack Point Control (0/1)\n", prefs.isKey("bpc") ? "*" : " ",bpc);

  if (init) brightness = prefs.getShort("brightness", 0);
  c += sprintf(c, " %s brightness: %i    \tBrightness (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("brightness") ? "*" : " ",brightness);

  if (init) colorbar = prefs.getShort("colorbar", 0);
  c += sprintf(c, " %s colorbar: %i      \tTest Colour Bars (0/1)\n", prefs.isKey("colorbar") ? "*" : " ",colorbar);

  if (init) contrast = prefs.getShort("contrast", 0);
  c += sprintf(c, " %s contrast: %i      \tContrast (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("contrast") ? "*" : " ",contrast);

  if (init) denoise = prefs.getShort("denoise", 0);
  c += sprintf(c, " %s denoise: %i       \tDenoise (0/1, OV5640 = 0:auto to 8)\n", prefs.isKey("denoise") ? "*" : " ",denoise);

  if (init) dcw = prefs.getShort("dcw", 1);
  c += sprintf(c, " %s dcw: %i           \tDownsize image to requested size (0/1)\n", prefs.isKey("dcw") ? "*" : " ",dcw);

  if (init) framesize = prefs.getShort("framesize", 0);
  c += sprintf(c, " %s framesize: %i [%s]\tLive Stream Frame Size (as 'size' - above): \n", prefs.isKey("framesize") ? "*" : " ",framesize, frameSizes[framesize].c_str());

  if (init) gainceiling = (gainceiling_t)prefs.getShort("gainceiling", 3);
  c += sprintf(c, " %s gainceiling: %i  \tGain Ceiling (0-6, OV3660/OV5640: 511)\n", prefs.isKey("gainceiling") ? "*" : " ",gainceiling);

  if (init) hmirror = prefs.getShort("hmirror", 0); // My OV5640 needs this set
  c += sprintf(c, " %s hmirror: %i       \tHorizontal Mirror (0/1)\n", prefs.isKey("hmirror") ? "*" : " ",hmirror);

  if (init) lenc = prefs.getShort("lenc", 1);
  c += sprintf(c, " %s lenc: %i          \tLens Correction (0/1)\n", prefs.isKey("lenc") ? "*" : " ",lenc);

  if (init) quality = prefs.getShort("quality", 10);
  c += sprintf(c, " %s quality: %i      \tQuality (4-63, OV3660: 6?-63, OV5640: 8-63)\n", prefs.isKey("quality") ? "*" : " ",quality);

  if (init) raw_gma = prefs.getShort("raw_gma", 1);
  c += sprintf(c, " %s raw_gma: %i       \tRaw Gamma (0/1)\n", prefs.isKey("raw_gma") ? "*" : " ",raw_gma);

  if (init) vflip = prefs.getShort("vflip", 0);
  c += sprintf(c, " %s vflip: %i         \tVertical Mirror (0/1)\n", prefs.isKey("vflip") ? "*" : " ",vflip);

  if (init) saturation = prefs.getShort("saturation", 0);
  c += sprintf(c, " %s saturation: %i \tSaturation (-2 to 2, OV3660/OV5640: -4 to 4)\n", prefs.isKey("saturation") ? "*" : " ", saturation);

  if (init) sharpness = prefs.getShort("sharpness", 0);
  c += sprintf(c, " %s sharpness: %i     \tSharpness (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("sharpness") ? "*" : " ",sharpness);

  if (init) special_effect = prefs.getShort("special_effect", 0);
  c += sprintf(c, " %s special_effect: %i\tSpecial Effect", prefs.isKey("special_effect") ? "*" : " ", special_effect);
  c += sprintf(c, " (0-6) 0 nothing, 1 Negative, 2 Greyscale, 3 Red Tint, 4 Green Tint, 5 Blue Tint, 6 Sepia\n");

  if (init) wb_mode = prefs.getShort("wb_mode", 0);
  c += sprintf(c, " %s wb_mode: %i       \tWhite Balance Mode (if awb_gain enabled)", prefs.isKey("wb_mode") ? "*" : " ",  wb_mode);
  c += sprintf(c, " (0-4) 0 - Auto, 1 - Sunny, 2 - Cloudy, 3 - Office, 4 - Home\n");

  if (init) wpc = prefs.getShort("wpc", 1);
  c += sprintf(c, " %s wpc: %i           \tWhite Point Control (0/1)\n", prefs.isKey("wpc") ? "*" : " ",wpc);

  if (init) xclk = prefs.getShort("xclk", 12);
  c += sprintf(c, " %s xclk: %i         \tClock Frequency (4-30)\n", prefs.isKey("xclk") ? "*" : " ",xclk);

  // This setting isn't a part of the camera API, but you can set and save it using the sensor settings API.
  if (init) max_fps = prefs.getFloat("max_fps", 24);
  c += sprintf(c, " %s max_fps: %.2f   \tLive Stream Frame Rate Limit (fps). (0.5 = 1 frame every 2 seconds) 0 to disable limiting.\n", prefs.isKey("max_fps") ? "*" : " ",max_fps);

  c += sprintf(c, "\n Settings marked * have been set and saved to NVS. Other settings are defaults.\n");

  // For when switching sensors or whatever..
  if (ae_level < minAELevel || ae_level > maxAELevel) { ae_level = 0; prefs.putShort("ae_level", 0); }
  if (brightness < minBrightness || brightness > maxBrightness) { brightness = 0; prefs.putShort("brightness", 0); }
  if (contrast < minContrast || contrast > maxContrast) { contrast = 0; prefs.putShort("contrast", 0);}
  if (denoise < minDenoise || denoise > maxDenoise) { denoise = 0; prefs.putShort("denoise", 0); }
  if (gainceiling < minGC || gainceiling > maxGC) { gainceiling = 3; prefs.putShort("gainceiling", 3); }
  if (saturation < minSaturation || saturation > maxSaturation) { saturation = 0; prefs.putShort("saturation", 0); }
  if (sharpness < minSharpness || sharpness > maxSharpness) { sharpness = 0; prefs.putShort("sharpness", 0); }

  c += sprintf(c, " %s\n", getFreeEntries().c_str());

  return (String)buffer;
}



/*
    OKAY! Let's DO IT!
                          */

/*
  Iterate files in the root directory (the only directory we deal with*).

  While here, we can create file listings, count files, delete files, and so on, so this function is
  re-used a fair bit.

  This function handles the listing part of the generation of plain text listing over the serial
  connexion, filelist-style plain text HTML listings, as well as thumbnails.

  While here for /full/ listings, we count the files and tally all file sizes to produce estimates
  about how many more images you might be able to save on your particular SD Card. Listings for the
  web can be /partial/, so we don't gather such data while serving web clients.

  You can wipe a *range* of files, supplying start, and optionally end indexes. These indexes are
  the number they would appear at if you used the command: list

  If required, you could wipe a single file like so: wipe 18-18, but using the web interface is
  much, much easier.

  When we need new functionality that involves iterating the files, we throw it in here.

  Returns a String.

  * If you are using one small part of your SD Card over and over, consider creating a directory in
  the root and then, over time, putting ever-increasingly large files in it, so you  don't wear out
  that part of your SD Card, rendering the whole thing useless (at least, unless you created some
  tricky partition).

  Or you might be fortunate enough to own a 4GB microSD that does its own wear-levelling, but this
  is highly unlikely.

  NOTE: If you want to do your own wear-levelling, create a directory in the root. Put your wear-
        levelling files in there and they will be incorporated into the free-space calculations.
*/


String listDir(bool isSerial = false, bool webDisplay = false, bool webList = false, \
  bool deleteFiles = false, bool INIT = false, uint64_t delX = 0, uint64_t delZ = UINT64_MAX) {

  if (ejected) return " SD Card is Ejected!";

  uint32_t fileCount = 0;
  uint64_t currentTime = millis();


#if defined ONLINE
  String webString = "";
  // We use the same String for either of these, but not both together.
  if (webDisplay || webList) webString.reserve(memReserve);
#endif

  String serialString = "";
  if (isSerial) serialString.reserve(memReserve);

  if ( (isSerial || webList) && !deleteFiles) serialString.concat("\n Listing Images..\n");

  File root = SD_MMC.open("/");
  if ( !root || !root.isDirectory()) {
    ABORT = " Problem with root directory. Check your SD Card.";
    return ABORT;
  }

  File file;
  String fileName;

  bool qlTMP = quickListings;
#if defined ONLINE
  // If we are displaying a web page of thumbs/links, we skip full checking for all files UP
  // UNTIL the first file we are listing, then switch over to full details for only /those/ files.
  if (webDisplay) {
    qlTMP = (displayFrom != 1) ? true : false; // Dynamic quickList state.
  }
#endif

  // We always use quickListings for deletions..
  if (deleteFiles) qlTMP = true;

  if (qlTMP) {
    fileName = root.getNextFileName();
    if (fileName == "") return "";
  } else {
    file = root.openNextFile(); // <- Dig the slowness
    if (!file) return "";
  }

  uint64_t dFiles = 0;
  String realName;


  while (true) {

    if (qlTMP) {
      realName = fileName.substring(1);
    } else {
      realName = (String)file.name();
    }

    if (realName == "") break;

    if (realName.indexOf(fileEXT) == -1) {
      if (qlTMP) {
        fileName = root.getNextFileName();
        if (fileName == "") break;
      } else {
        file.close();
        file = root.openNextFile();
        if (!file) break;
      }
      continue;
    }

    // We are deleting..
    if (deleteFiles) {

      dFiles++;

      // All done with deletions..
      if (dFiles > delZ) break;

      if (dFiles < delX) {
        fileName = root.getNextFileName();
        if (fileName == "") break;
        continue;
      }

      if (SD_MMC.remove("/" + realName) ) {
        serialString.concat(" deleted: [" + (String)dFiles + "] " + realName + " OK!\n");
        fileCount--;
      } else {
        serialString.concat(" FAILED to delete: " + realName + " OK!\n");
      }

    } else {

      // Not deleting..

      fileCount++;

      char fileSize[12] = {'\0'};
      // Create a time + date strings for this file..
      // We use two separate buffers so that we can shrink the display for small/tiny thumbnails.
      char dateBuffer[32] = {'\0'};
      char timeBbuffer[32] = {'\0'};

      if (!qlTMP) {

        float_t kbSize = file.size()/1024.00;

        if (showDecimalSize) {
          sprintf(fileSize, "%.2f", kbSize);
        } else {
          sprintf(fileSize, "%.0f",  kbSize);
        }

        if (useRealTime) {
          time_t t= file.getLastWrite();
          struct tm * tmstruct = localtime(&t);
          sprintf(dateBuffer, "%d-%02d-%02d", (tmstruct->tm_year)+1900, (tmstruct->tm_mon)+1, tmstruct->tm_mday);
          sprintf(timeBbuffer, "%02d:%02d:%02d", tmstruct->tm_hour , tmstruct->tm_min, tmstruct->tm_sec);
        }
      }

      /*
         Serial Console list..
                                  */

      if (isSerial) {
        serialString.concat(" [" + (String)fileCount + "] " + realName);
        if (!qlTMP) {
          serialString.concat("  [" + (String)fileSize + "kB]");

          if (useRealTime) serialString.concat("  [" + (String)dateBuffer + " @ " + (String)timeBbuffer + "]");
        }
        serialString.concat("\n");
      }

#if defined ONLINE

      if (webList) {
        webString.concat(" [" + (String)fileCount + "]\t" + realName);
        if (!qlTMP) {
          webString.concat("\t[" + (String)fileSize + "kB]");
          if (useRealTime) webString.concat("\t[" + (String)dateBuffer + " @ " + (String)timeBbuffer + "]");
        }
        webString.concat("\n");
      }


      /*
         Main Web Page..
                        */



      if (webDisplay && (fileCount >= displayFrom) && (fileCount < (displayFrom + imagesPerPage) ) ) {

        /*
          Here Be Thumbnails.

          NOTE: The actual, whole image is loaded into each thumbnail, scaled down by your browser.

                                                 */

        String displayName = realName;
        displayName.replace(fileEXT, "");
        displayName.replace(fileNamePrefix, "");

        String displaySize = (String)fileSize;
        uint16_t nLen = displayName.length();

        // Spit out a thumbnail box for..
        if (thumbView) {

          // Limit for how big thumbnails need to be before attempting to show certain elements
          // above the thumbnail, e.g. size information.
          uint8_t kBL = (showDecimalSize) ? 130 : 105;
          int16_t cutOFF;

          webString.concat("<div class=\"thumbbox\" ");

          if (doPoP) {
            // This may throw up an error if you hover before the page is loaded, but is more reliable than a handler.
            webString.concat("onpointerenter=\"showPoP('" + realName + "')\" onpointerleave=\"hidePoP()\" ");
          }
          webString.concat("id=\"" + realName + "\">");
          webString.concat("<button class=\"delbutt\" title=\"delete this image\"");
          webString.concat(" onclick='deleteImage(\"" + realName + "\")'>x</button>"); // MY viewport width is set to this ---133-->V
          webString.concat("<a href=\"" + realName + "\" title=\"[" + (String)fileCount + "" + realName + " [" + fileSize + "kB]");
          webString.concat("
\nClick to view in a new tab, shift-click to start a slideshow here - add Ctrl for full-screen\"");
          webString.concat("target=\"_blank\"><span class=\"filename\" title=\"no slideshow action here - just a regular link\">");

          // Because we want to truncate in either direction, it's not feasible to use CSS for this. Syat.
          switch (thumbWidth) {
            case 105 ... 215 :
              cutOFF = ( thumbWidth / 10 ) - 4;
              break;
            default :
              cutOFF = ( thumbWidth / 10 );
          }

          if (!truncateReverse) { // !
            displayName = displayName.substring(0, cutOFF); // truncate here
          } else { // a bit nasty, but only edge-cases will feel it.
            displayName = displayName.substring( nLen - ( (cutOFF > nLen) ? nLen : cutOFF ) );
          }

          webString.concat(displayName); //                                  &#8198; == 6-per-em space
          if (!qlTMP && thumbWidth >= kBL) {
            webString.concat("<span class=\"size-info\">&#8198;&#x200D;[" + displaySize + "kB]</span>");
          }
          webString.concat("</span>");
          /*
            We set the *HEIGHT* as 3/4 of the chosen width, to achieve a) a full-sized thumbnail at the correct ratio and
            b) enable proper flowing of thumbnails where sizes may differ. Odd-sized images will gracefully scale to fit.
          */

          webString.concat("<br><img class=\"thumb\" style=\"height:" + (String)(thumbWidth*0.75) + "px;\" src=\"" + realName);

          // If you insist thumbnail widths must all match, use this instead..
          //webString.concat("<br><img class=\"thumb\" style=\"width:" + (String)thumbWidth + "px;\" src=\"" + realName);

          // But if you are messing with image sizes, expect big gaps in your thumbnail page.

          webString.concat("\" onclick=\"openSlideShowAt('" + realName + "')\" alt=\"Thumbnail Image: " + realName + "\" >");

          if (!qlTMP && useRealTime) {
            webString.concat("
<span class=\"datefoot\">");
            // &#x200A;&#x200D; == Hair Space + Zero Width Joiner. Regular &nbsp; also works, but is bigger. Theoretically.
            if (thumbWidth >= 130) webString.concat((String)dateBuffer + "
&#x200A;&#x200D;@&#x200A;&#x200D;");
            if (thumbWidth >= 55) webString.concat((String)timeBbuffer);
          }
          webString.concat("
</span></a></div>\n");


        // Plain-ish index-type listing..

        } else {

          String space = "
";
          if (spacesInListView) space = "
 pad-left";

          webString.concat("
<div class=\"filelist\" ");
          if (doPoP) {
            webString.concat("
onpointerenter=\"showPoP('" + realName + "')\" onpointerleave=\"hidePoP()\" ");
          }
          webString.concat("
id=\"" + realName + "\"><a class=\"file-link\" href=\"" + realName + "\" ");
          if (doPoP) webString.concat("
onclick=\"openSlideShowAt('" + realName + "')\" ");
          webString.concat("
title=\"[" + (String)fileCount + "] " + realName);
          if (!qlTMP) webString.concat(" [" + displaySize + "kB]");
          if (doPoP) webString.concat("\nshift-click to start a slideshow here");
          webString.concat("\" target=\"_blank\">[" + (String)fileCount + "" + realName);
          webString.concat("
<span class=\"dummy" + space +"\"></span>");
          // These two 'hide' classes do the size-collapsing..
          if (!qlTMP) {
            webString.concat("
<span class=\"list-hide" + space + "\">[" + displaySize + "kB]</span>");
            // If you want the file list names to collapse timestamp-before-size, switch these two CSS classes around.
            if (useRealTime) {
              webString.concat("
<span class=\"list-wide-hide" + space + "\">[" + (String)dateBuffer);
              webString.concat("
<span class=\"dummy" + space +"\"></span>@<span class=\"dummy" + space +"\"></span>");
              webString.concat((String)timeBbuffer + "
]</span>");
            }
          }
          webString.concat("
</a><button class=\"listdelbutt" + space + "\" title=\"delete this image\"");
          webString.concat("
 onclick='deleteImage(\"" + realName + "\")'>[x]</button></div>");
        }
      }

      // We can at least break out once we have enough files for web display purposes..
      if (webDisplay && fileCount > (displayFrom + imagesPerPage) ) break;
#endif

    }

    bool doFile = true, switching = false;
#if defined ONLINE

    if (qlTMP) {
      doFile = false;
      // Switch to full listings mode for our selected file range..
      if (displayDetails && webDisplay && (fileCount >= (displayFrom-1)) ) {
        qlTMP = false;
        doFile = true;
        switching = true;
// Serial.printf("------->SWITCHING @ %.2f seconds\n", (float_t)(millis() - currentTime) / 1000);
      } else {
        fileName = root.getNextFileName();
        if (fileName == "
") break;
      }
    }
#endif

    if (doFile) {
      if (!switching) file.close(); // would work fine without checking switching var, but this feels safer.
      file = root.openNextFile();
      if (!file) break;
    }

  } // end while() loop


  // Stop the clock!
  float_t finishedAt = (float_t)(millis() - currentTime) / 1000;

#if defined ONLINE
  // Return Web Page..
  if (webDisplay) return webString;
#endif

  String finished = "
\n Finished processing in " + (String)finishedAt + " seconds\n";
  if (isSerial) serialString.concat(finished);
#if defined ONLINE
  if (webList) webString.concat(finished);
#endif

  if (deleteFiles) {
    saveNumber = (delX == 0) ? 0 : delX-1; // we might save some time down the line
    if (isSerial) serialString.concat("
\n Done\n");
  } else {

    if (fileCount > 0) {
      totalFileCount = fileCount;
      if ((INIT || isSerial || webList) && fileCount != 0) {
        averageSize = ( SD_MMC.usedBytes() - levellingBytes ) / totalFileCount;
      }
    }

    if (INIT && totalFileCount > 0) saveNumber = totalFileCount; // A starting point only
    char buff[200] = {'\0'};
    // sprintf(buff, "\n File system interrogated in %.2f seconds\n", finishedAt);
    sprintf(buff + strlen(buff), "
 Total files: %i\n", fileCount);

    uint64_t remainEst = (float_t)(SD_MMC.totalBytes() - SD_MMC.usedBytes() - levellingBytes) / averageSize;
    sprintf(buff + strlen(buff), "
 Space for approx. %llu (avg: %llukB) images", remainEst, (averageSize / 1024));

    if (isSerial) serialString.concat(buff);
#if defined ONLINE
    if (webList) webString.concat("
\n " + (String)buff + "\n");
#endif
    if (INIT) return buff;
  }

  if (isSerial) return serialString;
#if defined ONLINE
  if (webList) return webString;
#endif
  return "
"; // Never happens.
}



// Quicker way to list all the files.
// No files are opened, no file sizes calculated or dates checked; it's just a list. But it is FAST.
String quickList(bool report = true, bool allFiles = false) {

  if (ejected) return "
 SD Card is Ejected!";

  File root = SD_MMC.open("
/");
  if ( !root || !root.isDirectory()) {
    ABORT = "
 Problem with root directory. Check your SD Card.";
    return ABORT;
  }

  String list = "
";
  // Should be enough for 10,000 images easy..
  list.reserve(512000);
  if (report) list = "
 Current (quick) file list:\n\n";

  uint32_t fileCount = 0;
  String file = root.getNextFileName();
  if (file == "
") return " Empty Card";

  uint64_t currentTime = millis();

  while (file != "
") {
    if (file.indexOf(fileEXT) != -1 || allFiles) {
      fileCount++;
      if (report) list.concat("
  [" + (String)fileCount + "" + file.substring(1) + "\n");
    }
    file = root.getNextFileName();
  }

  list.concat("
\n Quick List completed in " + (String)((float_t)(millis() -currentTime) / 1000) + " seconds\n");
  list.concat("
 Total Files: " + (String)fileCount + "\n");

  if (fileCount > 0) {
    totalFileCount = fileCount;
    averageSize = ( SD_MMC.usedBytes() - levellingBytes ) / totalFileCount;
  }

  return list;
}




// For TEST purposes..
String notQuickLst(bool allFiles = false) {

  if (ejected) return "
 SD Card is Ejected!";

  File root = SD_MMC.open("
/");
  if ( !root || !root.isDirectory()) {
    ABORT = "
 Problem with root directory. Check your SD Card.";
    return ABORT;
  }

  uint32_t fileCount = 0;
  String thisName;
  uint64_t currentTime = millis();
  File file = root.openNextFile();

  String list;
  list.reserve(512000);
  list = "
 Current (not quick) file list:\n\n";

  while (file) {
    thisName = (String)file.name();
    if (allFiles || thisName.indexOf(fileEXT) != -1) {
      fileCount++;
      list.concat("
  [" + (String)fileCount + "" + thisName.substring(1) + "\n");
    }
    file.close();
    file = root.openNextFile();
  }

  list.concat("
\n NOT Quick List completed in " + (String)((float_t)(millis() -currentTime) / 1000) + " seconds\n");
  list.concat("
 Total Files: " + (String)fileCount + "\n");
  return list;
}



/*
  Wipe NVRAM/NVS  aka. "
Non Volatile Storage".

  You can do this from the serial console, with the command: nvswipe

                 */

void WipeNVRAM() {
  esp_err_t ret = nvs_flash_init();
  ESP_ERROR_CHECK(nvs_flash_erase()); // The actual wipe
  ret = nvs_flash_init();
  ESP_ERROR_CHECK(ret);
  Serial.println("
 NVRAM Erased.");
}



/*
Memory information..               */

String printMemoryInfo(bool full = false) {
  char mbuf[380];
  sprintf(mbuf, "
 Free memory: %d bytes\n", esp_get_free_heap_size());
  sprintf(mbuf + strlen(mbuf), "
 Task High Tide Watermark: %d bytes\n", uxTaskGetStackHighWaterMark(NULL) );
  if (full) {
    sprintf(mbuf + strlen(mbuf), "
 Available Internal Heap Size: %d\n", esp_get_free_internal_heap_size());
    sprintf(mbuf + strlen(mbuf), "
 Minimum Free Heap Ever Available Size: %d\n", esp_get_minimum_free_heap_size());
  }
  sprintf(mbuf + strlen(mbuf), "
 Total Heap: %d ~", ESP.getHeapSize());
  sprintf(mbuf + strlen(mbuf), "
 Free Heap: %d\n", ESP.getFreeHeap());
  return (String)mbuf;
}




/*
  Fire up the SD Card..
                       */

bool startMicroSD() {

  Serial.print("
\n Mounting microSD Card.. ");

  // Pin 13 needs to be pulled-up. We can do this in software.
  // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/sd_pullup_requirements.html#pull-up-conflicts-on-gpio13
  pinMode(13, INPUT_PULLUP);

  if (SD_MMC.begin("
/sdcard", true)) {
    Serial.println("
OK!");
  } else {
    Serial.println("
FAILED!");
    return false;
  }

  uint8_t cardType = SD_MMC.cardType();
  if (cardType == CARD_NONE) {
    Serial.println("
 No SD Card Attached!");
    return false;
  }

  Serial.print("
 SD Card Type: ");
  if (cardType == CARD_MMC) {
    Serial.println("
MMC");
  } else if (cardType == CARD_SD) {
    Serial.println("
SDSC");
  } else if (cardType == CARD_SDHC) {
    Serial.println("
SDHC");
  } else {
    Serial.println("
UNKNOWN");
  }

  Serial.printf("
 SD Card Size: %llu MB\n", SD_MMC.cardSize() / (1024 * 1024));

  // Check if user is doing manual wear-levelling and deduct this from the average size/free space calculations
  getWearLevellingBytes();

  Serial.printf("
 Checking SD Card contents..");

  if (quickListings) {
    Serial.println();
    Serial.println(quickList(false));
  } else {
    if ( (SD_MMC.usedBytes() / (1024 * 1024) ) < ( maxMBytesLimit + ( levellingBytes / 1024 / 1024) ) ) {
      Serial.println("
 (this could take a while)");
      Serial.println(listDir(false, false, false, false, true));
    } else {
      Serial.printf("
 SD Card used space greater than your %i MB limit. Listing aborted. \n", maxMBytesLimit);
    }
  }


  Serial.println(printSpace());
  return true;
}


// Manual Wear-Levelling

// Calculate the space used by manual wear-levelling files (or any other files inside directories)
// We simply do a quick list and check any file who's name doesn't include a dot (.).
// If it is a directory, we dive inside and add bytes for all files within.

void getWearLevellingBytes() {

  File dir = SD_MMC.open("
/");
  if (!dir || !dir.isDirectory()) return;

  levellingBytes = 4096; // Allow for "empty" FAT
  File file, testDir;
  String mySize, maybeDir = dir.getNextFileName();

  while (maybeDir != "
") {

    // No dot in the name - check if it is a directory..
    if (maybeDir.indexOf("
.") == -1) {

      testDir = SD_MMC.open(maybeDir);
      if (testDir && testDir.isDirectory()) {

        file = testDir.openNextFile();

        while (file) {  // %zu for size_t
          mySize = (String)(file.size()/1024) + "
kB";
          if (file.size() > (1024*1024)) mySize = (String)(file.size()/1024/1024) + "
MB";
          if (eXi) Serial.printf("
 + %s wear-levelling: %s/%s\n", mySize.c_str(), maybeDir.c_str(), file.name());
          levellingBytes += file.size();
          file = testDir.openNextFile();
        }
      }
      testDir.close();
    }
    maybeDir = dir.getNextFileName();
  }

  // For maximum accuracy, add bytes from the stream capture file, if it exists.
  String mjpegFileName = "
/" + streamFileName;
  if (SD_MMC.exists(mjpegFileName)) {
    File videoFile = SD_MMC.open(mjpegFileName);
    levellingBytes += videoFile.size();
    videoFile.close();
  }

}


// Print out remaining free SD space..
//
String printSpace() {

  char mbuf[164];
  sprintf(mbuf, "
 Total Space: %llu MB\t", SD_MMC.totalBytes() / (1024 * 1024) );

  uint64_t usedUserBytes = SD_MMC.usedBytes()-levellingBytes;

  if ( usedUserBytes < (1024 * 1024) ) {
    sprintf(mbuf + strlen(mbuf), "
 Used Space: %llu kB\n", usedUserBytes / 1024);
  } else {
    sprintf(mbuf + strlen(mbuf), "
 Used Space: %.2f MB\n", (float_t)(usedUserBytes / (1024 * 1024)) );
  }

  float_t freeSpace = (SD_MMC.totalBytes() - usedUserBytes);
  sprintf(mbuf + strlen(mbuf), "
 Available Free Space: %.2f MB\n", freeSpace / (1024 * 1024));
  sprintf(mbuf + strlen(mbuf), "
 Space for approx.: %llu (avg: %llukB) images", \
                            (uint64_t)(freeSpace / averageSize), (averageSize / 1024));

  return (String)mbuf;
}


/*
  If you are considering hacking your ESP32-CAM's circuit board to accommodate 4-bit SD Card
  operation with non-flickering LED, I recommend you forget it.

  I temporarily hacked TL-CAM to test out the differences in speed. I suspect the 4-bit
  implementation isn't very clever and IMHO, a roughly 6.5% increase in speed is not worth it.

  The data:

    Total files: 456
    List with quickListings DISABLED.

    1-bit:

    benchmark:
      Wrote 1048576 bytes in 4388ms == 238.00 KB/s
      Read 1048576 bytes in 521ms == 2012.00 KB/s

      List:
        File system interrogated in 31.58 seconds

    4-bit: (with super bright flashing LED throughout!)

      benchmark:
        Wrote 1048576 bytes in 4314ms == 243.00 KB/s
        Read 1048576 bytes in 369ms == 2841.00 KB/s

      List:
        File system interrogated in 29.59 seconds

 */




/*
  Simple SD Card Benchmark..

  With this information, we can calculate your shortest possible snap interval time, as well as get
  an idea of the expected thumbnail generation speed.

                   */

String benchmarkSD() {

  static char results[1024];
  char *b = results;
  b += sprintf(b, "
\n");

  // Generate some test data (i.e. gibberish)
  uint8_t *buf = (uint8_t *)malloc(64 * 1024);
  const char *path = "
/Speed-Test.bin";
  uint64_t start_time = millis();

  b += sprintf(b, "
\n Writing test file: \"%s\"\n", path);
  File file = SD_MMC.open(path, "
w"); // old-school way to specify write mode.

  if (!file) {
    b += sprintf(b, "
 Failed to open file for writing. Speed Test Aborted.\n");
    return results;
  }

  // We use the exact same mechanism and settings as picture taking.
  if (!file.write(buf, SDTestSize)) {
    // Serial.println(" Write Failed!");
    b += sprintf(b, "
 Write Failed!");
    return results;
  }

  file.close(); // flush and close

  uint32_t runTime = millis() - start_time;
  b += sprintf(b, "
 Wrote %i bytes in %ims == %.2f KB/s\n\n", SDTestSize, runTime, (float_t)(SDTestSize/runTime) );

  // Wait a sec..
  delay(1000);
  b += sprintf(b, "
 Reading test file \"%s\"\n", path);

  start_time = millis();
  file = SD_MMC.open(path);

  if (!file) {
    b += sprintf(b, "
 Failed to open file for reading!");
    return results;
  }

  if (!file.read(buf, SDTestSize)) {
    b += sprintf(b, "
 Read failed!");
    return results;
  }

  file.close();

  runTime = millis() - start_time;
  b += sprintf(b, "
 Read %i bytes in %ims == %.2f KB/s\n\n", SDTestSize, runTime, (float_t)(SDTestSize/runTime) );
  b += sprintf(b, "
 Benchmark complete.\n");

  SD_MMC.remove("
/Speed-Test.bin");

  return results;
}



/*

  Camera Setup..

                  */



bool configCamera(bool init=false) {

  camera_config_t config;

  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  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.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_UXGA; // 2MP
  config.jpeg_quality = quality;                    // temporary value
  config.xclk_freq_hz = (xclk * 1000000); // > MHz  // ditto
  config.fb_count = 2;
  config.fb_location = CAMERA_FB_IN_PSRAM;

  // This ensures we see the latest images, instead of one from the buffer's queue, which does confuse.
  config.grab_mode = CAMERA_GRAB_LATEST;

  // Set parameters based on whether or not we have extra memory..
  if (!psramFound()) {

    if (init) Serial.println("
\n PSRAM NOT FOUND: Maximum supported resolution is SVGA. Bummer.");
      config.fb_location = CAMERA_FB_IN_DRAM;
      config.frame_size = FRAMESIZE_SVGA;
      config.fb_count = 1;

  } else {
    if (init) Serial.println("
\n PSRAM found: Maximum resolution supported. Yippee!");
  }

  if (init) Serial.print("
 Initialising ESP32-CAM: ");
  esp_err_t err = esp_camera_init(&config);
  if (err == ESP_OK) {
    if (init) Serial.println("
OK");
  } else {
    if (init) Serial.println("
FAIL");
    return false;
  }

  return true;
}



// Initialise the camera..
//
bool startCamera(bool init=true) {

  Serial.print("
\n Starting Camera.. ");

  // Turn off the flash
  pinMode(FLASH_PIN, OUTPUT);
  digitalWrite(FLASH_PIN, LOW);

  // Initialize the hardware
  if (!configCamera(init)) return false;

  sensor_t *s = esp_camera_sensor_get();
  // For more information about these settings, see: https://heyrick.eu/blog/index.php?diary=20210418

  // For reference..
  // {"lamp":0,"autolamp":0,"framesize":11,"quality":12,"xclk":20,"min_frame_time":0,"brightness":0,"contrast":0,"saturation":0,"special_effect":0,"wb_mode":0,"awb":1,"awb_gain":1,"aec":1,"aec2":1,"ae_level":1,"aec_value":204,"agc":1,"agc_gain":0,"gainceiling":1,"bpc":1,"wpc":1,"raw_gma":1,"lenc":1,"vflip":0,"hmirror":0,"dcw":1,"colorbar":0,"rotate":"-90"}

  /*
    LARGE Image Sizes.

    If you have PSRAM on-board (for buffering image data), you can use /large/ image sizes.
    The default (maximum) large size for the default sensor (OV2640) is UXGA (1600x1200):

    If you are using the OV5640 (5MP) sensor, you can go up to QSXGA (2560x1920):

    see: ~/.arduino15/packages/esp32/hardware/esp32/<version>/tools/sdk/esp32/include/esp32-camera/driver/include/sensor.h

    NOTE: in the absence of actual saved images, this setting will also be used to estimate the
    remaining image space.
                              */



  Serial.print("
 Detected camera module: ");
  //TODO in controls - check against this int (FRAMESIZE_SXGA, etc) and stop producing <option> items when it passes this level.

  // Unless otherwise stated, it's VGA:  size           enum

  switch (s->id.PID) {

    case OV2640_PID :
      sensorType = "
OV2640";          // default 2MP sensor
      maxSize = FRAMESIZE_UXGA;       // 1600x1200      13
      supported = true;
      break;
    case OV3660_PID :
      sensorType = "
OV3660";          // 3MP sensor
      maxSize = FRAMESIZE_QXGA;       // 2048x1536      17
      minBrightness = -3;
      maxBrightness = 3;
      minContrast = -3;
      maxContrast = 3;
      minSaturation = -4;
      maxSaturation = 4;
      minAELevel = -5;
      maxAELevel = 5;
      minSharpness = -3;
      maxSharpness = 3;
      minDenoise = 0;
      maxDenoise = 8;
      minGC = 0;
      maxGC = 511;
      supported = true;
      break;
    case OV5640_PID :
      sensorType = "
OV5640";          // 5MP sensor
      maxSize = FRAMESIZE_QSXGA;      // 2560x1920       21
      minBrightness = -3;
      maxBrightness = 3;
      minContrast = -3;
      maxContrast = 3;
      minSaturation = -4;
      maxSaturation = 4;
      minAELevel = -5;
      maxAELevel = 5;
      minSharpness = -3;
      maxSharpness = 3;
      minDenoise = 0;
      maxDenoise = 8;
      minGC = 0;
      maxGC = 75; // BUG? not 511?
      supported = true;
      break;
    case OV9650_PID :
      sensorType = "
OV9650";
      maxSize = FRAMESIZE_SXGA;       // 1280x1024        12
      supported = false;
      break;
    case OV7725_PID :
      sensorType = "
OV7725";
      supported = false;
      break;
    case OV7670_PID :
      sensorType = "
OV7670";
      supported = false;
      break;
    case NT99141_PID :
      sensorType = "
NT99141";
      maxSize = FRAMESIZE_HD;         // 1280x720         11
      supported = false;
      break;
    case GC2145_PID :
      sensorType = "
GC2145";
      maxSize = FRAMESIZE_UXGA;       // 1600x1200        13
      supported = false;
      break;
    case GC032A_PID :
      sensorType = "
GC032A";
      supported = false;
      break;
    case GC0308_PID :
      sensorType = "
GC0308";
      supported = false;
      break;
    case BF3005_PID :
      sensorType = "
BF3005";
      supported = false;
      break;
    case BF20A6_PID :
      sensorType = "
BF20A6";
      supported = false;
      break;
    case SC101IOT_PID :
      sensorType = "
SC101IOT";
      maxSize = FRAMESIZE_HD;         // 1280x720          11
      supported = false;
      break;
    case SC030IOT_PID :
      sensorType = "
SC030IOT";
      supported = false;
      break;
    case SC031GS_PID :
      sensorType = "
SC031GS";         // monochrome
      supported = false;
  }

  Serial.println(sensorType);

  // Now we know the min/max values for the attached sensor, we can gather the user's sensor prefs,
  // setting limits as-we-go..
  String prefsString = gatherSensorPrefs(true);
  if (eXi) Serial.println(prefsString);


  /*
    Initial estimated average size

    In the absence of any real data; either because there are no saved images yet, or else there
    were so many saved images we didn't attempt to scan them; we can give the user a rough estimate
    of the number of images they can yet save to this particular microSD Card.

    An "
average" photo at maximum size on the OV2640 might be around 180kB, or 184320 bytes.

    Once real photos exist, we use the size of those.
    The more images you capture, the better these estimates get.

    The "
estimates" are mindfully high.
    As soon as you start taking pictures, the estimates get real.

    To get the current estimate. use the command: free

    NOTE: The list commands re-calculate these figures.
  */



  if (prefs.getString("
sensor") != sensorType) {
    prefs.putString("
sensor", sensorType);
    Serial.printf("
 Switching to NEW Sensor: %s\n", sensorType.c_str());
    // This is now handled automatically on each pref's load.
    // We still let the user know that we have recognised the change, for peace of mind.
  //   String clearedSettings = clearSensorSettings();
  //   Serial.printf(" %s", clearedSettings.c_str());
  }

  if (!supported) {
    Serial.println("
 This is an unsupported sensor. It may work fine.");
    Serial.println("
 PLEASE report your findings, along with any tweaks you applied,");
    Serial.println("
 if any, to /get/ it working to esp32 @ the usual domain");
    Serial.println("
 Thank you!");
  }

  // No framsize stored in prefs, load max size for this sensor..
  if (framesize == FRAMESIZE_96X96 || framesize < 1 || framesize > maxSize) framesize = maxSize;
  // We make the assumption that no one actually chooses this size as their default!

  // Now we know which sensor we have, we can check the capture size is within limits..
  if ( captureSize < 1 || captureSize > maxSize ) captureSize = maxSize;
  if (eXi) Serial.printf("
 Setting Capture frame size to: %i (%s)\n", captureSize, frameSizes[captureSize].c_str());

   // Lower resolution if we do not have PSRAM..
  if (!psramFound()) {
    if (framesize != FRAMESIZE_VGA) framesize = FRAMESIZE_SVGA; // SVGA = 800 x 600
    if (captureSize != FRAMESIZE_VGA) captureSize = FRAMESIZE_SVGA;
  }

  // Now we have the final streaming frame size..
  if (eXi) Serial.printf("
 Setting streaming frame size to: %i (%s)\n", framesize, frameSizes[framesize].c_str());

  // Set our final frame size..
  s->set_framesize(s, (framesize_t)framesize);

  // Initially, OV3660 sensors are flipped vertically and colors / brightness varies. Create defaults here if you like.
  if (s->id.PID == OV3660_PID) {
    s->set_vflip(s, 1);       // flip it back
    // s->set_brightness(s, 1);  // up the brightness just a bit
    // s->set_saturation(s, 2); // The saturation on my OV3660 sensor is low.
  }

  //
  if (s->id.PID == OV5640_PID) {

    // my regular OV5640 need the horizontal mirror flipped. My OV5640 AF does not.
    // However, my OV5640 AF needs the vertical flipped. Go figure.
    s->set_hmirror(s, 1);
  }

  // Set to user values..
  s->set_xclk(s, LEDC_TIMER_0, xclk);
  s->set_quality(s, quality);

  s->set_ae_level(s, ae_level);
  s->set_aec2(s, aec2);
  s->set_aec_value(s, aec_value);
  s->set_agc_gain(s, agc_gain);
  s->set_awb_gain(s, awb_gain);
  s->set_bpc(s, bpc);
  // Only set these user prefs if they exist, otherwise use above overrides.
  if (prefs.isKey("
brightness")) s->set_brightness(s, brightness);
  s->set_colorbar(s, colorbar);
  s->set_contrast(s, contrast);
  s->set_dcw(s, dcw);
  s->set_denoise(s, denoise);
  s->set_exposure_ctrl(s, aec);
  s->set_gain_ctrl(s, agc);
  s->set_gainceiling(s, (gainceiling_t)gainceiling);
  if (prefs.isKey("
hmirror")) s->set_hmirror(s, hmirror);
  s->set_lenc(s, lenc);
  s->set_raw_gma(s, raw_gma);
  if (prefs.isKey("
saturation")) s->set_saturation(s, saturation);
  s->set_sharpness(s, sharpness);
  s->set_special_effect(s, special_effect);
  if (prefs.isKey("
vflip")) s->set_vflip(s, vflip);
  s->set_wb_mode(s, wb_mode);
  s->set_whitebal(s, awb);
  s->set_wpc(s, wpc);

  return true;
}


// Sensor settings..
// Adapted from the official example.
String setSensorParameters(String variable, String value, bool report = true) {

  if (variable == "
" || value == "") return "INVALID SETTING! Use: c setting=value";

  int16_t val = value.toInt();

  char buffer[4096] = {'\0'};
  sprintf(buffer, "
 Setting Sensor: %s = %s .. ", variable.c_str(), value.c_str());

  sensor_t *s = esp_camera_sensor_get();
  int16_t ret = 0;
  String xTra = "
";

  // We don't trust user input. Most commands fail gracefully. But not all. So..

  // Capture frame size..
  if (variable == "
size") {
    if ( val < 1 || val > maxSize ) {
      xTra = "
(size remains " + frameSizes[captureSize] + ")";
      ret = -1;
    } else {
      captureSize = val;
    }

  // Live stream frame limiting..
  } else if (variable == "
max_fps") {

    float_t fps = value.toFloat();
    if (fps < 0 || fps > 100) {
      fps = max_fps;
      xTra = "
(limit remains " + (String)max_fps + "fps)";
      ret = -1;
    }
    max_fps = fps;

  // Clock Frequency..
  } else if (variable == "
xclk") {

    if (val < 4 || val > 30) val = xclk;
    xclk = val;
    ret = s->set_xclk(s, LEDC_TIMER_0, xclk); // Here we *DO NOT* multiply by a million.


  // From here it's standard sensor settings..

  } else if (variable == "
ae_level") {

    if (val < minAELevel || val > maxAELevel) val = ae_level;
    ae_level = val;
    ret = s->set_ae_level(s, ae_level);

  } else if (variable == "
aec") {

    if (val < 0 || val > 1) val = aec;
    aec = val;
    ret = s->set_exposure_ctrl(s, aec);

  }  else if (variable == "
aec2") {

    if (val < 0 || val > 1) val = aec2;
    aec2 = val;
    ret = s->set_aec2(s, aec2);

  } else if (variable == "
aec_value") {

    if (val < 0 || val > 1200) val = aec_value;
    aec_value = val;
    ret = s->set_aec_value(s, aec_value);

  } else if (variable == "
agc") {

    if (val < 0 || val > 1) val = awb;
    agc = val;
    ret = s->set_gain_ctrl(s, agc);

  } else if (variable == "
agc_gain") {

    if (val < 0 || val > 30) val = agc_gain;
    agc_gain = val;
    ret = s->set_agc_gain(s, agc_gain);

  } else if (variable == "
awb") {

    if (val < 0 || val > 1) val = awb;
    colorbar = val;
    ret = s->set_whitebal(s, val);

  } else if (variable == "
awb_gain") {

    if (val < 0 || val > 1) val = awb_gain;
    awb_gain = val;
    ret = s->set_awb_gain(s, awb_gain);

  } else if (variable == "
bpc") {

    if (val < 0 || val > 1) val = bpc;
    bpc = val;
    ret = s->set_bpc(s, bpc);

  } else if (variable == "
brightness") {

    if (val < minBrightness || val > maxBrightness) val = brightness;
    brightness = val;
    ret = s->set_brightness(s, brightness);

  } else if (variable == "
colorbar") {

    if (val < 0 || val > 1) val = colorbar;
    colorbar = val;
    ret = s->set_colorbar(s, colorbar);

  } else if (variable == "
contrast") {

    if (val < minContrast || val > maxContrast) val = contrast;
    contrast = val;
    ret = s->set_contrast(s, contrast);

  } else if (variable == "
dcw") {

    if (val < 0 || val > 1) val = dcw;
    dcw = val;
    ret = s->set_dcw(s, dcw);

  } else if (variable == "
denoise") {

    if (val < minDenoise || val > maxDenoise) val = denoise;
    denoise = val;
    ret = s->set_denoise(s, denoise);

  } else if (variable == "
framesize") {

    if (val < 0 || val > maxSize) val = framesize;
    framesize = val;
    ret = s->set_framesize(s, (framesize_t)framesize);
    // xTra += t->width+ "x" + t->height;

  } else if (variable == "
gainceiling") {

    if (val < minGC || val > maxGC) val = gainceiling;
    gainceiling = val;
    ret = s->set_gainceiling(s, (gainceiling_t)gainceiling);

  } else if (variable == "
hmirror") {

    if (val < 0 || val > 1) val = hmirror;
    hmirror = val;
    ret = s->set_hmirror(s, hmirror);

  } else if (variable == "
lenc") {

    if (val < 0 || val > 1) val = lenc;
    lenc = val;
    ret = s->set_lenc(s, lenc);

  } else if (variable == "
quality") {

    if (val < 4 || val > 63) val = quality;
    quality = val;
    ret = s->set_quality(s, quality);

  } else if (variable == "
raw_gma") {

    if (val < 0 || val > 1) val = raw_gma;
    raw_gma = val;
    ret = s->set_raw_gma(s, raw_gma);

  } else if (variable == "
saturation") {

    if (val < minSaturation || val > maxSaturation) val = saturation;
    saturation = val;
    ret = s->set_saturation(s, saturation);

  } else if (variable == "
sharpness") {

    if (val < minSharpness || val > maxSharpness) val = sharpness;
    sharpness = val;
    ret = s->set_sharpness(s, sharpness);

  } else if (variable == "
special_effect") {

    if (val < 0 || val > 6) val = special_effect;
    special_effect = val;
    ret = s->set_special_effect(s, special_effect);

  } else if (variable == "
vflip") {

    if (val < 0 || val > 1) val = vflip;
    vflip = val;
    ret = s->set_vflip(s, vflip);

  } else if (variable == "
wpc") {

    if (val < 0 || val > 1) val = wpc;
    wpc = val;
    ret = s->set_wpc(s, wpc);

  } else if (variable == "
wb_mode") {

    if (val < 0 || val > 4) val = wb_mode;
    wb_mode = val;
    ret = s->set_wb_mode(s, wb_mode);

  } else {
    ret = -1;
  }

  // All good..
  if (ret == 0) {

    sprintf(buffer + strlen(buffer), "
OK %s\n", xTra.c_str() );
    LastMessage = (String)buffer;
    if (eXi && report) Serial.print(buffer);

    // Save to NVS..
    if (variable == "
max_fps") {
      prefs.putFloat(variable.c_str(), val);
    } else {
      prefs.putShort(variable.c_str(), val);
    }
    return "
OK! " + xTra;

  } else {
    sprintf(buffer + strlen(buffer), "
Command Failed %s\n", xTra.c_str() );
    LastMessage = (String)buffer;
    if (eXi && report) Serial.print(buffer);
    return "
FAIL" + xTra;
  }
}



// Backup and restore camera config using built-in NVS functions..
String saveSensorConfig(const char *slotName = "
backup") {
  bool ret = esp_camera_save_to_nvs(slotName);
  if (ret == ESP_OK) return "
OK";
  return "
FAIL";
}


String loadSensorConfig(const char *slotName = "
backup") {
  bool ret = esp_camera_load_from_nvs(slotName);
  saveSensorSettingsToDefaults();
  if (ret == ESP_OK) return "
OK";
  return "
FAIL";
}

/*
    The following sensor settings are saved when using esp_camera.h's built-in NVS save function..

      s->set_ae_level(s, s.ae_level);
      s->set_aec2(s, s.aec2);
      s->set_aec_value(s, s.aec_value);
      s->set_agc_gain(s, s.agc_gain);
      s->set_awb_gain(s, s.awb_gain);
      s->set_bpc(s, s.bpc);
      s->set_brightness(s, s.brightness);
      s->set_colorbar(s, s.colorbar);
      s->set_contrast(s, s.contrast);
      s->set_dcw(s, s.dcw);
      s->set_denoise(s, s.denoise);
      s->set_exposure_ctrl(s, s.aec);
      s->set_framesize(s, s.framesize);
      s->set_gain_ctrl(s, s.agc);
      s->set_gainceiling(s, s.gainceiling);
      s->set_hmirror(s, s.hmirror);
      s->set_lenc(s, s.lenc);
      s->set_quality(s, s.quality);
      s->set_raw_gma(s, s.raw_gma);
      s->set_saturation(s, s.saturation);
      s->set_sharpness(s, s.sharpness);
      s->set_special_effect(s, s.special_effect);
      s->set_vflip(s, s.vflip);
      s->set_wb_mode(s, s.wb_mode);
      s->set_whitebal(s, s.awb);
      s->set_wpc(s, s.wpc);

    // not saved..

      xclk (Clock Frequency).
      captureSize (TL-CAM custom)
      max_fps (TL-CAM custom)

  After restoring sensor settings from the NVS backup, we re-save those settings to our
  regular NVS slot, so they are the defaults on boot-up.

                                      */

String saveSensorSettingsToDefaults() {

  char buffer[4096] = {'\0'};
  sensor_t * s = esp_camera_sensor_get();

  ae_level = s->status.ae_level;
  prefs.putShort("
ae_level", ae_level);
  sprintf(buffer + strlen(buffer), "
 ae_level: %i\n", ae_level);

  aec2 = s->status.aec2;
  prefs.putShort("
aec2", aec2);
  sprintf(buffer + strlen(buffer), "
 aec2: %i\n", aec2);

  aec = s->status.aec;
  prefs.putShort("
aec", aec);
  sprintf(buffer + strlen(buffer), "
 aec: %i\n", aec);

  aec_value = s->status.aec_value;
  prefs.putShort("
aec_value", aec_value);
  sprintf(buffer + strlen(buffer), "
 aec_value: %i\n", aec_value);

  agc = s->status.agc;
  prefs.putShort("
agc", agc);
  sprintf(buffer + strlen(buffer), "
 agc: %i\n", agc);

  agc_gain = s->status.agc_gain;
  prefs.putShort("
agc_gain", agc_gain);
  sprintf(buffer + strlen(buffer), "
 agc_gain: %i\n", agc_gain);

  awb = s->status.awb;
  prefs.putShort("
awb", awb);
  sprintf(buffer + strlen(buffer), "
 awb: %i\n", awb);

  awb_gain = s->status.awb_gain;
  prefs.putShort("
awb_gain", awb_gain);
  sprintf(buffer + strlen(buffer), "
 awb_gain: %i\n", awb_gain);

  bpc = s->status.bpc;
  prefs.putShort("
bpc", bpc);
  sprintf(buffer + strlen(buffer), "
 bpc: %i\n", bpc);

  brightness = s->status.brightness;
  prefs.putShort("
brightness", brightness);
  sprintf(buffer + strlen(buffer), "
 brightness: %i\n", brightness);

  colorbar = s->status.colorbar;
  prefs.putShort("
colorbar", 0);
  sprintf(buffer + strlen(buffer), "
 colorbar: %i\n", colorbar);

  contrast = s->status.contrast;
  prefs.putShort("
contrast", contrast);
  sprintf(buffer + strlen(buffer), "
 contrast: %i\n", contrast);

  dcw = s->status.dcw;
  prefs.putShort("
dcw", dcw);
  sprintf(buffer + strlen(buffer), "
 dcw: %i\n", dcw);

  denoise = s->status.denoise;
  prefs.putShort("
denoise", denoise);
  sprintf(buffer + strlen(buffer), "
 denoise: %i\n", denoise);

  framesize = s->status.framesize;
  prefs.putShort("
framesize", framesize);
  sprintf(buffer + strlen(buffer), "
 framesize: %i [%s]\n", framesize, frameSizes[framesize].c_str());

  gainceiling = s->status.gainceiling; // (gainceiling_t)s->status.gainceiling
  prefs.putShort("
gainceiling", gainceiling);
  sprintf(buffer + strlen(buffer), "
 gainceiling: %i\n", gainceiling);

  hmirror = s->status.hmirror;
  prefs.putShort("
hmirror", hmirror); // My OV5640 needs this set, but my OV5640 AF does NOT. How weird.
  sprintf(buffer + strlen(buffer), "
 hmirror: %i \n", hmirror); // Also, how to tell them apart?

  lenc = s->status.lenc;
  prefs.putShort("
lenc", lenc);
  sprintf(buffer + strlen(buffer), "
 lenc: %i\n", lenc);

  quality = s->status.quality;
  prefs.putShort("
quality", quality);
  sprintf(buffer + strlen(buffer), "
 quality: %i\n", quality);

  raw_gma = s->status.raw_gma;
  prefs.putShort("
raw_gma", raw_gma);
  sprintf(buffer + strlen(buffer), "
 raw_gma: %i\n", raw_gma);

  saturation = s->status.saturation;
  prefs.putShort("
saturation", saturation);
  sprintf(buffer + strlen(buffer), "
 saturation: %i\n", saturation);

  sharpness = s->status.sharpness;
  prefs.putShort("
sharpness", sharpness);
  sprintf(buffer + strlen(buffer), "
 sharpness: %i\n", sharpness);

  special_effect = s->status.special_effect;
  prefs.putShort("
special_effect", special_effect);
  sprintf(buffer + strlen(buffer), "
 special_effect: %i\n", special_effect);

  vflip = s->status.vflip;
  prefs.putShort("
vflip", vflip);
  sprintf(buffer + strlen(buffer), "
 vflip: %i\n", vflip);

  wb_mode = s->status.wb_mode;
  prefs.putShort("
wb_mode", wb_mode);
  sprintf(buffer + strlen(buffer), "
 wb_mode: %i\n", wb_mode);

  wpc = s->status.wpc;
  prefs.putShort("
wpc", wpc);
  sprintf(buffer + strlen(buffer), "
 wpc: %i\n", wpc);

  return (String)buffer;

}



// getSensorStatus() helper function..
static int print_reg(char * p, sensor_t * s, uint16_t reg, uint32_t mask, bool json=false) {
  // char delim = (json) ? ',' : '\n';
  if (json) {
    return sprintf(p, "
\"0x%x\":%u,", reg, s->get_reg(s, reg, mask));
  } else {
    return sprintf(p, "
    0x%x: %u\n", reg, s->get_reg(s, reg, mask));
  }
}



// From the standard example, ish.
//
String getSensorStatus(bool print=false, bool extra=false, bool json=false) {

    static char sensorData[1240] = {'\0'};

    sensor_t *s = esp_camera_sensor_get();
    char *p = sensorData;
    char delim = '\n';

    if (json) {
      *p++ = '{';
      delim = ',';
    }

      if (extra) {
        if (!json) p += sprintf(p, "
\n Register Values:\n\n");

        if(s->id.PID == OV5640_PID || s->id.PID == OV3660_PID) {

          for(int reg = 0x3400; reg < 0x3406; reg+=2) {
              p+=print_reg(p, s, reg, 0xFFF, json);// 12 bit
          }
          p+=print_reg(p, s, 0x3406, 0xFF, json);

          p+=print_reg(p, s, 0x3500, 0xFFFF0, json);// 16 bit
          p+=print_reg(p, s, 0x3503, 0xFF, json);
          p+=print_reg(p, s, 0x350a, 0x3FF, json);  // 10 bit
          p+=print_reg(p, s, 0x350c, 0xFFFF, json); // 16 bit

          for(int reg = 0x5480; reg <= 0x5490; reg++) {
              p+=print_reg(p, s, reg, 0xFF, json);
          }

          for(int reg = 0x5380; reg <= 0x538b; reg++) {
              p+=print_reg(p, s, reg, 0xFF, json);
          }

          for(int reg = 0x5580; reg < 0x558a; reg++) {
              p+=print_reg(p, s, reg, 0xFF, json);
          }
          p+=print_reg(p, s, 0x558a, 0x1FF, json);  // 9 bit

          // if(s->id.PID == OV5640_PID) {
          //   for(int reg = 0x6000; reg < 0x603F; reg++) {
          //     p+=print_reg(p, s, reg, 0xFF, json);
          //   }
          // }

        } else if(s->id.PID == OV2640_PID) {

          p+=print_reg(p, s, 0xd3, 0xFF, json);
          p+=print_reg(p, s, 0x111, 0xFF, json);
          p+=print_reg(p, s, 0x132, 0xFF, json);
        }
      }

    if (!json) p += sprintf(p, "
\n Sensor Status:\n\n");

    p += sprintf(p, "
    \"sensor\": \"%s\"%c" , sensorType.c_str(), delim);

    p += sprintf(p, "
    \"ae_level\": %d%c" , s->status.ae_level, delim);
    p += sprintf(p, "
    \"aec2\": %u%c" , s->status.aec2, delim);
    p += sprintf(p, "
    \"aec\": %u%c" , s->status.aec, delim);
    p += sprintf(p, "
    \"aec_value\": %u%c" , s->status.aec_value, delim);
    p += sprintf(p, "
    \"agc\": %u%c" , s->status.agc, delim);
    p += sprintf(p, "
    \"agc_gain\": %u%c" , s->status.agc_gain, delim);
    p += sprintf(p, "
    \"awb\": %u%c" , s->status.awb, delim);
    p += sprintf(p, "
    \"awb_gain\": %u%c" , s->status.awb_gain, delim);
    p += sprintf(p, "
    \"bpc\": %u%c" , s->status.bpc, delim);
    p += sprintf(p, "
    \"brightness\": %d%c" , s->status.brightness, delim);
    p += sprintf(p, "
    \"colorbar\": %u%c", s->status.colorbar, delim);
    p += sprintf(p, "
    \"contrast\": %d%c" , s->status.contrast, delim);
    p += sprintf(p, "
    \"dcw\": %u%c" , s->status.dcw, delim);
    p += sprintf(p, "
    \"denoise\": %u%c" , s->status.denoise, delim);
    p += sprintf(p, "
    \"framesize\": %u%c" , s->status.framesize, delim);
    p += sprintf(p, "
    \"gainceiling\": %u%c" , s->status.gainceiling, delim);
    p += sprintf(p, "
    \"hmirror\": %u%c" , s->status.hmirror, delim);
    p += sprintf(p, "
    \"lenc\": %u%c" , s->status.lenc, delim);
    p += sprintf(p, "
    \"pixformat\": %u%c" , s->pixformat, delim);
    p += sprintf(p, "
    \"quality\": %u%c" , s->status.quality, delim);
    p += sprintf(p, "
    \"raw_gma\": %u%c" , s->status.raw_gma, delim);
    p += sprintf(p, "
    \"saturation\": %d%c" , s->status.saturation, delim);
    p += sprintf(p, "
    \"sharpness\": %d%c" , s->status.sharpness, delim);
    p += sprintf(p, "
    \"special_effect\": %u%c" , s->status.special_effect, delim);
    p += sprintf(p, "
    \"vflip\": %u%c" , s->status.vflip, delim);
    p += sprintf(p, "
    \"wb_mode\": %u%c" , s->status.wb_mode, delim);
    p += sprintf(p, "
    \"wpc\": %u%c" , s->status.wpc, delim);
    if (!json) p += sprintf(p, "
\n");
    p += sprintf(p, "
    \"xclk\": %u%c" , s->xclk_freq_hz / 1000000, delim);
    p += sprintf(p, "
    \"max_fps\": %.2f", max_fps);

    if (json) {
      *p++ = '}';
      *p++ = 0;
    }

    if (eXi || print) Serial.println(sensorData);
    return sensorData;
}



/*
  Take a photo and get the image data into the frame buffer.
  Then save the data to the SD Card.

                                */

bool takePhoto(String filename) {

  if (ejected) return "
 SD Card is Ejected!";

  uint8_t setFramesize = 0;

  sensor_t *s = esp_camera_sensor_get();
  uint16_t currentFrameSize = s->status.framesize;

  if (ledBrightness != 0) {
    ledcWrite(ledChannel, ledBrightness);
    delay(50);
  }
  if (currentFrameSize != captureSize) {
    setFramesize = currentFrameSize;
    s->set_framesize(s, (framesize_t)captureSize);
    delay(250);
  }

  // Capture the image into memory located @ pointer *fb
  camera_fb_t *fb = NULL;
  fb = esp_camera_fb_get();

  if (!fb) {
    Serial.println("
 ERROR: Failed to capture an image!");
    return false;
  }

  // Switch off LED.
  if (ledBrightness != 0) {
    delay(250);
    ledcWrite(ledChannel, 0);
  }

  // Get dimensions..
  if (addDimensions != "
") {
    uint16_t picWidth, picHeight;
    picWidth = fb->width;
    picHeight = fb->height;
    imgDimensions = (String)picWidth + "
x" + (String)picHeight;
    addDimensions.replace("
%x", imgDimensions); // we might do other tokens later, see.
    filename += addDimensions;
  }

  // Save the picture data to the SD card..

  // Add the correct extension
  filename += fileEXT;
  // Open (create) the file for writing..
  File file = SD_MMC.open(filename.c_str(), FILE_WRITE);
  if (file) {
    Serial.println("
 Saving: " + filename.substring(1));
    file.write(fb->buf, fb->len);
    file.flush();
    file.close();
    // Save this to display at /recent
    mostRecentCapture = filename;
  } else {
    ABORT = "
 Unable to write " + filename + "\n Please fix this issue and reboot.";
  }

  if (setFramesize != 0) {
    s->set_framesize(s, (framesize_t)currentFrameSize);
  }

  // Update total size and average size..
  if (mostRecentCapture == filename) {
    totalFileCount++;
    averageSize = ( SD_MMC.usedBytes() - levellingBytes ) / totalFileCount;
  }

  esp_camera_fb_return(fb);
  fb = NULL;
  return true;
}



// Take a snap but DO NOT reset the timer..

bool instantPic() {
  saveNumber++;
  String filename = makeFilename(saveNumber);
  if ( filename != "
" && takePhoto(filename) ) return true;
  return false;
}



// For testing a newly selected brightness level..
void flashLED(uint16_t flashTime = 500) {
  if (ledBrightness != 0) {
    ledcWrite(ledChannel, ledBrightness);
    delay(flashTime);
    ledcWrite(ledChannel, 0);
  }
}


// Retrieve the stored UTC from NVS..
bool getStoredTime() {
  storedTime = prefs.getULong64("
UTC", 1679897227); // Mon Mar 27 2023 07:07:07
  // + seconds since boot..
  storedTime += millis() / 1000;
  setStoredTime(storedTime);
  return true;
}


// Set the current time from UTC value and store to NVS..
bool setStoredTime(uint64_t newUTC) {
  storedTime = newUTC;
  struct timeval tv;
  tv.tv_sec = storedTime;
  settimeofday(&tv, NULL);
  prefs.putULong64("
UTC", storedTime);
  return true;
}


// Store current UTC to NVS..
void storeUTC() {
  struct timeval tv;
  gettimeofday(&tv, NULL);
  storedTime = tv.tv_sec;
  Serial.printf("
 Saving UTC: %llu", storedTime);
  prefs.putULong64("
UTC", storedTime);
}




/*
  "
Simple" stream recording facility.

  Start stream recording from any console with the command: record
  Stop stream recording with the command: stop

  Simple.

  NOTE: If a previous recording exists, it will be overwritten.
        If you want to save a recording, use the download facility in the prefs panel.

  NOTE: If you load/refresh the web interface after starting a recording from the console, the
        recording indicator will be pulsing. Also the record button will be a stop button, which
        you can use to stop the recording at any time.

*/

void simpleStreamRecord() {

  if (amStreaming) {
    Serial.println("
 Live stream is already running!");
    return;
  }
#if defined ONLINE
  if (ejected) { sendEjected(); return; }
#endif
  File mjpegFile;
  String mjpegFileName = "
/" + streamFileName;
  if (SD_MMC.exists(mjpegFileName)) SD_MMC.remove(mjpegFileName);

  uint8_t failCount = 0;
  static int64_t last_frame = 0;
  if(!last_frame) last_frame = esp_timer_get_time();
  uint64_t frameCounter = 0;

  char filename[64];
  sprintf(filename, mjpegFileName.c_str());
  mjpegFile = SD_MMC.open(filename, FILE_WRITE);
  if (!mjpegFile) {
    Serial.println("
 Error creating video file");
    return;
  }

  amStreaming = true;
  Serial.printf("
 Recording mjpeg stream to:%s\n", filename);
  amRecording = true;
  camera_fb_t *fb;

  while (true) {

    // Capture a frame.
    fb = NULL;
    fb = esp_camera_fb_get();

    if (!fb) {
      failCount++;
      Serial.println("
 ERROR: Failed to capture an image!");
      if (failCount == 10) {
        Serial.println("
 10 failures. Something is wrong with your image settings.\n Please fix and try again.");
        break;
      }
    }

    frameCounter++;

    // Save frame data to a file..
    mjpegFile.print("
\r\n\r\n");
    mjpegFile.write(fb->buf, fb->len);
    mjpegFile.print("
\r\n\r\n");

    esp_camera_fb_return(fb);

    loop();

    if (eXi && frameCounter % 100 == 0) Serial.printf("
 Stream Recording: wrote %llu frames.\n", frameCounter);

    // Frame rate limiting..
    if (max_fps != 0) {
      int64_t frame_time = (esp_timer_get_time() - last_frame) / 1000;
      int32_t userDelay = 1000 / max_fps;
      int32_t frame_delay = (userDelay > frame_time) ? userDelay - frame_time : 0;
      delay(frame_delay);
      last_frame = esp_timer_get_time();
    }

    // Finish recording stream..
    if (!doRecording) {
      mjpegFile.printf("
%llu frames written\r\n", frameCounter);
      mjpegFile.close();
      Serial.printf("
 Stream Recording complete. Wrote %llu frames.\n", frameCounter);
      amRecording = false;
      break;
    }
  }

  fb = NULL;
  amStreaming = false;
}




// Main online code block..

#if defined ONLINE


/*
   Update time from NTP server, set RTC and print out time info..

   Once this is set, the ESP32 will keep time in sync with the server automatically.

   This is called only at INIT.
                                         */


void printLocalTime() {

  Serial.print("
 Getting Time..");
  bool fallBack = false;

  // No time yet..
  if (!getLocalTime(&timeinfo)) {

    if (realTimeRequired) {

      if (retryCount > maxNTPRetries) {
        if (useStoredTime) {
          fallBack = true;
          return;
        } else {
          Serial.println("
 Time Server is malfunctioning. Giving up now.\n Check your NTP Settings.");
          xCommand = "
x";
          return;
        }
      }

      retryCount++;
      delay(3000);
      Serial.println("
 Failed to get internet time, but Real Time is REQUIRED. Retrying..");
      printLocalTime();

    } else {

      Serial.println("
 Failed to obtain internet time");
      if (useStoredTime) fallBack = true;
    }

    if (fallBack) {
      getStoredTime();
      setenv("
TZ", timeZone, 1);
      tzset();
      Serial.println("
\n Reverting to stored time."); //TODO option
    }


  } else {
    Serial.print("
 Success.\n");
    Serial.print("
 Setting Internal Clock to: ");
    Serial.println(&timeinfo, "
%H:%M:%S, %A, %B %d %Y");
  }

}



/*
   Start Online & Remote Control Features..
                                            */

void startOnlineFeatures() {

  uint64_t currentTime = millis();
  uint64_t startTime = currentTime;
  bool gotNet = true;
  bool gotAP = true;

  // Setup default-checking variables..
  INITimagesPerPage = imagesPerPage;
  INITthumbWidth = thumbWidth;

  Serial.println("
\n Going Online..\n");

  if (!remControl) {
    Serial.println("
 Remote Control Features Disabled.");
  } else if (softAPSSID == "
") {
    Serial.println("
 Soft Access Point Disabled.");
  }

  if (remControl && onlyAP && softAPSSID != "
") {

    WiFi.mode(WIFI_AP);
    if (!isEmptyChar(&SGhostName)) WiFi.setHostname(SGhostName);

    Serial.println("
\n Running in Access Point Only Mode.");

  } else {

    WiFi.mode(WIFI_AP_STA); // The default == AP + Station
    if (!isEmptyChar(&SGhostName)) WiFi.setHostname(SGhostName);

    if (!isEmptyChar(&ssid)) {

      Serial.printf("
 Connecting to %s", ssid);
      WiFi.begin(ssid, password);

      while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.print("
."); // ... debug info in your console
        currentTime = millis();
        if (currentTime > (startTime + (timeOut * 1000) )) {
          Serial.printf("
\n Connecting to %s FAILED.\n", ssid);
          useRealTime = false;
          gotNet = false;
          break;
        }
      }
      if (WiFi.status() == WL_CONNECTED) {
        if (remControl) {
          Serial.print("
 Success!\n Local WiFi Web Address: http://");
          Serial.print(WiFi.localIP());
          Serial.println("
/");
        } else {
          Serial.println("
 Success!\n Internet Connexion Established.");
        }
      }
    } else { gotNet = false; }
  }

  // Access Point..
  if (remControl && softAPSSID != "
") {

    // You can customise the IP address.. (default is 192.168.4.1)
    IPAddress local_IP(192,168,4,1); // NOTE: commas.
    IPAddress gateway(192,168,4,1);
    IPAddress subnet(255,255,255,0);
    WiFi.softAPConfig(local_IP, gateway, subnet);

    if (WiFi.softAP(softAPSSID, softAPPass) ) {
      Serial.print("
\n WiFi Access Point Established.\n Connect your WiFi to ");
      Serial.println("
\"" + softAPSSID + "\".");
      Serial.print("
 Once connected to the AP, point your browser to: http://");
      Serial.print(WiFi.softAPIP());
      Serial.println("
/");
    } else { gotAP = false; }
  } else { gotAP = false; }


  // TL-CAM also available at its very own host name..
  if (remControl && (gotAP || gotNet) && !isEmptyChar(&SGhostName) ) {
    Serial.println("
\n You can also access your TL-CAM here: http://" + (String)WiFi.getHostname() + "/\n");
  }

  // Get Time Actual from the internet time server..
  if (gotNet && useRealTime) {
    configTzTime(timeZone, ntpServer);
    printLocalTime();
    if (!getLocalTime(&timeinfo)) return;
  }

  if (remControl) {

    // OKAY, we have at least one web interface running..

    if (gotAP || gotNet) {

      // WiFi.printDiag(Serial); // Print WiFi connexion details to serial interface (SSID, password, etc.) // .. debug

      // Initialise server..
      // server = new WebServer(WiFi.localIP(), 80);

      // Setup URI handling for main web server..

      // Root / Main page
      server.on("
/", sendRoot);

      // // The groovy console for serial-over-web commands..
      // server.on("/console", sendWebConsole);

      // Favicon.
      // Your browser will ask for this with every request (and cache it), so here it is, in SVG..
      server.on("
/favicon.ico", handleFavicon);

      // 404 errors.. Or are they? (probably not)
      // This is used to handle image requests, as well as serial-over-web commands from /console
      server.onNotFound(universalHandler);

      // TL-CAM will, by default, stream the most recent capture to /recent
      // This isn't a "live" stream, in that it won't update automatically or anything like that;
      // It simply presents the most recently-captured image. Unless you deleted it.
      server.on("
/recent", handleRecent);

      // Take a snap right now and display it in the browser..
      server.on("
/capture", handleCapture);

      // Live stream from camera.
      server.on("
/mjpeg", handleLiveStream);
      server.on("
/stream", handleLiveStream);

      // Start/Stop video stream recording..
      server.on("
/switch", handleSwitchRecording);

      // Download stream recording (if it exists)..
      server.on("
/download", handleDownloadRecording);

      // regular slideshow (TODO: a page for this, with controls that vanish)..
      server.on("
/slides", handleSlides);
      // e.g. /slides?slide=1000


      // Streaming SlideShow..
      server.on("
/slideshow", handleStreamingSlideShow);
      server.on("
/skip", handleSSSSkip);
      server.on("
/sp", handleSSSPause);

      // Returns the filename of the currently playing slide
      server.on("
/currentSlideName", handleReportCurrentSlide);

      // Upload files to SD card..
      // We could simply repeat the ufn, but a Lambda function is nicer, and makes me look cool.
      server.on("
/upload", HTTP_POST, [](){}, handleUPLOADFile);


      // AJAX requests..

      // Delete an image
      server.on("
/delete", handleDeleteFile);

      // Take a snap right now..
      server.on("
/snap", handleTakeSnapNow);

      // Change the snap interval..
      server.on("
/interval", handleIntervalChange);

      // Check the current snap interval
      server.on("
/getint", handleGetInterval);

      // SD Card actions..
      server.on("
/eject", handleEjectSD);
      server.on("
/insert", handleInsertSD);

      // SD space info
      server.on("
/info", handleSDInfo);

      // We provide web versions of useful commands, generally ones with larger output.

      // Current file list..
      server.on("
/L", handleFileList);
      server.on("
/l", handleFileList);

      // Current FULL file list (and recalculate space, etc.)..
      server.on("
/List", handleFullFileList);
      server.on("
/list", handleFullFileList);

      // Quick file list..
      server.on("
/QuickList", handleQuickFileList);
      server.on("
/quicklist", handleQuickFileList);
      server.on("
/ql", handleQuickFileList);
      server.on("
/qla", handleQuickAllFileList);

      // Commands help (new consoles get this, also sending empty commands)
      server.on("
/help", handleCommandsHelp);

      // Sensor Status..
      server.on("
/sensor", handleSensorStatus);

      // Sensor Status (with registers information)..
      server.on("
/sensorx", handleSensorStatusEx);

      // Print out stored NVS Settings..
      server.on("
/nvs", handlePrintNVS);

      // Memory info
      server.on("
/memory", handleMemoryInfo);

      // Set sensor settings..
      server.on("
/xclk", handleImageSettings);
      server.on("
/sensorset", handleImageSettings);

      server.on("
/control", handleESP32ControlWrap);

      // Re-usable Settings handler..
      server.on("
/settings", handleCameraSettings);

      // Poll here to get reboot status..
      server.on("
/rebooted", handleRebootStatus);

      // Backup to NVS..
      server.on("
/backup", handleBackupNVS);

      // Restore from NVS..
      server.on("
/restore", handleRestoreNVS);

      // Pick up command responses here..
      server.on("
/LastMessage", handleLastMessage);


      // "official" API stuff
      server.on("
/status", handleESPSensorStatus);
      server.on("
/reg", handleESP32SetReg);
      server.on("
/greg", handleESP32GetReg);
      server.on("
/pll", handleESP32pll);
      server.on("
/resolution", handleESP32WindowRes);


      // Instruct web server to collect cookie header with requests. We use Cookies extensively.
      const char *Keys[1] = {"
Cookie"}; // Arbitrary memory storage space
      server.collectHeaders(Keys, 1);


      // Fire up the server..
      server.begin();
      Serial.println("
\n Web Server Started. Enjoy!");

    } else {

      Serial.println("
\n\n Connecting to WiFi FAILED.\n Web Controls Disabled.\n");
      remControl = false;
    }
  }
}


// Send a 500 error code to the browser..
void send500(String msg = "
fail") {
  server.send(500, _TEXT_, msg);
}


// Switch Stream recording on/off..

void handleSwitchRecording() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  doRecording = doRecording ? false : true;
  String msg = doRecording ? "
Stream Recording Started" : "Stream Recording Stopped";
  if (!amStreaming) {
    server.send(200, _TEXT_, "
OK " + msg);
    silentStream = true;
    handleLiveStream();
  } else {
    server.send(200, _TEXT_, "
OK " + msg);
    silentStream = false;                   //BUG ?? set here?
  }
}


/*
  Motion-JPEG streaming function that works with ESP32's standard WebServer.h.

  Handy for setting up your sensor/shots, or simply moonlighting as a surveillance cam.

  This function handles the web interface live stream view as well as the web-based MJPEG stream
  recording to a file, even simultaneously.

  You can get to this feature from the web interface buttons or directly at /mjpeg or /stream.
  The stream is viewable in VLC (Ctrl+N) and other quality video players with network capabilities.

  The /recorded/ stream is also, unlike most other MJPEG files you will find online, playable in
  VLC/ffplay/etc..

  You can start/stop recording the stream by toggling the switch at /switch, or by hitting the
  record button or apostrophe key (') in the web interface. A wee pulsing red dot will let you know
  when you are recording and the record button will switch to a stop button (square) so you can
  also click to stop recording.

                        */

void handleLiveStream() {

  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (amStreaming) {
    server.send(500, _TEXT_, "
Live stream is already running!");
    return;
  }

  WiFiClient client;

  // silentStream is set when recording is initiated while the live stream is *not* running.
  // ergo, silentStream is only set up here if called from handleSwitchRecording()
  if (!silentStream) {
    client = server.client();
    client.write(headerData, strlen(headerData));
    client.write(contentType, strlen(contentType));
    client.write(boundaryMark, bmLen);
  }

  uint8_t failCount = 0;
  camera_fb_t *fb = NULL;
  char hbuff[32];
  uint32_t frame_size;
  uint64_t showTime = 0;

  amStreaming = true;
  File mjpegFile;

  uint64_t frameCounter = 0;

  // Loop until client disconnects or we finish recording; whichever comes last.
  while (true) {

    // You can start and stop recording while the stream is running,
    // or close the stream while recording is in progress. No problem.
    if (silentStream && !doRecording) break;
    if (!silentStream && !client.connected() && !amRecording) break;

    // Process any serial commands, web requests, scheduled updates, whatever.
    // All that stuff takes precedence over a live stream.
    loop(); // BOOM! This feels clever.
    // And makes surprisingly little difference to the frame rate. We re-use this trick elsewhere.

    // Frame rate limiting.
    // If you have a fast capture rate, this can help ease the burden.
    if (max_fps == 0 || ( max_fps != 0 && ( millis() > ( showTime + (1000 / max_fps) ) ) ) ) {

      // Initialise stream recording. Happens once per recording.
      if (doRecording && !amRecording) {

        String mjpegFileName = "
/" + streamFileName;
        if (SD_MMC.exists(mjpegFileName)) SD_MMC.remove(mjpegFileName);

        char filename[64];
        sprintf(filename, mjpegFileName.c_str());
        mjpegFile = SD_MMC.open(filename, FILE_WRITE);
        if (!mjpegFile) {
          Serial.println("
 Error creating video file");
          amStreaming = false;
          return;
        }
        if (eXi) Serial.printf("
 Recording mjpeg stream to:%s\n", filename);
        amRecording = true;
        frameCounter = 0;
      }

      // Capture a frame.
      fb = NULL;
      fb = esp_camera_fb_get();
      frame_size = fb->len;

      // Recording to a file..
      if (amRecording) {

        // A wee trick to get the recorded stream file to play in VLC, ffplay, et al.
        mjpegFile.print("
\r\n\r\n");
        mjpegFile.write(fb->buf, fb->len);
        mjpegFile.print("
\r\n\r\n");
        // Note: NONE of the downloaded "sample" MJPEG files I tested would play in VLC. This does. Boom!
      }

      if (!fb) {
        failCount++;
        Serial.println("
 ERROR: Failed to capture an image!");
        if (failCount == 10) {
          Serial.println("
 10 failures. Something is wrong with your image settings.\n Please fix and try again.");
          break;
        }
      }

      // Write out frame buffer to client..
      if (!amRecording || streamANDRecord) {
        client.write(frameType, ctLen);
        sprintf(hbuff, "
%d\r\n\r\n", frame_size);
        client.write(hbuff, strlen(hbuff));
        client.write((char *)fb->buf, frame_size);
        client.write(boundaryMark, bmLen);
      }

      frameCounter++;
      esp_camera_fb_return(fb);

      // Update frame timer..
      showTime = millis();

      if (amRecording && eXi && frameCounter % 100 == 0) Serial.printf("
 Stream Recording: wrote %llu frames.\n", frameCounter);

      // Finish recording stream..
      if (amRecording && !doRecording) {
        mjpegFile.printf("
%llu frames written\r\n", frameCounter);
        mjpegFile.close();
        if (eXi) Serial.printf("
 Stream Recording complete. Wrote %llu frames.\n", frameCounter);
        amRecording = false;
      }

    }
  }

  // last_frame = 0;
  fb = NULL;
  amStreaming = false;

}



/*
  Download the recorded video stream..

/download                      */

void handleDownloadRecording() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) { sendEjected(); return; }

  String mjpegFileName = "
/" + streamFileName;

  if ( !SD_MMC.exists(mjpegFileName) ) {
    server.send(404);
  } else {

    File mjpegFile = SD_MMC.open(mjpegFileName);
    if (!mjpegFile) return;

    server.sendHeader("
Content-Disposition", "Attachment; filename=" + streamFileName);
    if (eXi) Serial.printf("
 Serving MJPEG File: %s\n", mjpegFileName.c_str());
    server.streamFile(mjpegFile, "
video/mjpeg");
    mjpegFile.close();
  }
}



/*
  File Upload

  You can use this to upload files to the root directory of the currently inserted* microSD card.

  Simply send you POST data to /upload

  Or use the handy button in the prefs panel **.

  NOTE: If the file already exists on the ESP32 device, your new upload will replace it. No warning.

  I should have mentioned already; using the word "
fail" in an error response triggers the
  JavaScript WARNING color for the message; probably red (in the CSS).


  * By "
inserted" I mean physically, as well as intra-physically; either with the "sd" or "insert"
    commands, or boot-up.

  ** The cute prefs panel button is a single-click, no-nonsense control. Click it and you will be
     presented with a file chooser, to choose a file from your local filesystem. We remain in one-
     click territory*** and the instant you select a file it will be uploaded, replacing any
     existing file of the same name and; the whole point; available for immediate viewing at..

       /your-file-name.html

  *** Back in the day I would help local folk with "
PeeCees" and invariably I would switch their
      desktop to one-click operation in the process of helping. The emails! lol. But always worth
      it. Every click costs.


/upload                 */

void handleUPLOADFile() {

  if (ejected) { sendEjected(); return; }

  HTTPUpload& upload = server.upload();

  if (upload.status == UPLOAD_FILE_START) {

    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

    String filename = "
/" + upload.filename;
    if ( SD_MMC.exists(filename) ) SD_MMC.remove(filename);

    File file = SD_MMC.open(filename, FILE_WRITE);
    if (!file) {
      server.send(500, _TEXT_, "
Failed to create file");
      return;
    }

    Serial.println("
 Uploading file: " + filename);

  } else if (upload.status == UPLOAD_FILE_WRITE) {

    if (upload.currentSize > 0) {
      File file = SD_MMC.open("
/" + upload.filename, FILE_APPEND);
      if (!file) {
        server.send(500, _TEXT_, "
Failed to open file");
        return;
      }
      file.write(upload.buf, upload.currentSize);
      file.close();
    }

  } else if (upload.status == UPLOAD_FILE_ABORTED) {

    if ( SD_MMC.exists(upload.filename) ) SD_MMC.remove(upload.filename);
      server.send(500, _TEXT_, "
Upload Failed. User Aborted");
      return;

  } else if (upload.status == UPLOAD_FILE_END) {

    if (upload.totalSize == 0) {
      server.send(500, _TEXT_, "
UPLOAD FAILED. NO DATA RECEIVED!");
      return;
    }

    server.send(200, _TEXT_, "
File uploaded successfully");
    Serial.println("
 File uploaded successfully");
  }
}



/*
  Return cookie value, if it exists. If not, return the default value.

  Handles regular booleans, "
special" booleans like "seen", as well as integer values.

  Returns a boolean String ("
true"/"false").
                                                                                       */

String cOOkiEChomp(String &Cookies, String cOOkiE, bool defaultValue, String special="
", bool isNotBool=false, uint8_t intVal=0) {

  if (Cookies.indexOf(cOOkiE) != -1) {
    String cookieTest;
    // Pluck out the value (everything between the following '=' and the next ';')..
    cookieTest = Cookies.substring( Cookies.indexOf(cOOkiE)+cOOkiE.length()+1, Cookies.indexOf( "
;", Cookies.indexOf(cOOkiE) ) );
    cookieTest.trim();
    if (cookieTest != "
") {
      setCookie(cOOkiE, cookieTest); // Refresh this cookie while we're here.
      if (isNotBool) return cookieTest;
      // The Ternary Operator is most beautiful when nesting..
      return (special != "
") ? ((cookieTest == special) ? "true" : "false") : ((cookieTest == "true") ? "true" : "false");
    }
  } else {
    if (isNotBool) return (String)intVal; // will return 0 if no darkmode cookie found
    return (defaultValue == true) ? "
true" : "false";
  }
  return "
";
}


// Set a Cookie (Or refresh one, as they these days have an age limit.)
void setCookie(String cOOkiE, String value) {
  server.sendHeader("
Set-Cookie", cOOkiE + "=" + value + ";Path=/;HttpOnly;samesite=Strict;Expires=Fri, 31 Dec 9999 23:59:59 GMT");
}                                                                                                  // Or 400 days, if it's Chrome.


bool toBool(String inputString) {
  inputString.trim();
  inputString.toLowerCase();
  if (inputString == "
true") return true;
  return false;
}




/*
  Streaming SlideShow..

  We use the same mechanism as the live stream, but instead of sensor frames, we pump images at you on a timer.

  Send ?start=<number> to start the slideshow at a different index.

  You can pause and resume the Streaming SlideShow with the command:

    sp

  Also from the web:

    /sp

  You can also skip forward; e.g. to skip forward 100 images you can do..

    skip 100

  or..

    skip100

  And from the web:

    /skip100
    /skip 100

  or using the web handler..

    /skip?skip=100


/slideshow             */

void handleStreamingSlideShow() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) { sendEjected(); return; }
  if (sShowPlaying) { sendAlreadyPlaying(); return; }

  File root = SD_MMC.open("
/");
  if ( !root || !root.isDirectory()) {
    server.send(200, _TEXT_, "
 Problem with root directory. Check your SD Card.");
    return;
  }

  sssSkip = 0;
  sssPause = false;
  sShowPlaying = true;

  uint32_t start = 1;
  if (server.hasArg("
start")) start = server.arg("start").toInt();

  WiFiClient client = server.client();
  client.write(headerData, strlen(headerData));
  client.write(contentType, strlen(contentType));
  client.write(boundaryMark, bmLen);

  char hbuff[32];
  String fileName;
  uint32_t fileIndex = 0;
  uint64_t showTime = 0;

  // Fast Forward to starting number, if there is one..
  while (fileIndex+1 < start) {
    fileName = root.getNextFileName();
    if (fileName.indexOf(fileEXT) != -1) fileIndex++;
  }

  // Begin opening files now..
  File file = root.openNextFile();
  String realName;
  uint32_t imgSize;

  while (file) {

    realName = (String)file.name();
    imgSize = file.size();

    if (!client.connected()) {
      if (eXi) Serial.println("
 Streaming slideshow connexion closed.");
      break;
    }
    if (sssPause) showTime = millis();

    // Skip command received..
    while (sssSkip > 0) {
      showTime = 0;
      sssSkip--;
      fileName = root.getNextFileName();
      if (fileName == "
") break;
      fileIndex++;
    }

    if (sssSkip == -1) {
      showTime = 0;
      sssSkip = 0;
    }

    // Show Time!
    if ( millis() > (showTime + (slideTime * 1000)) ) {

      // Ignore non-images (will immediately try next file)
      if (realName.indexOf(fileEXT) != -1) {

        if (!chromeHack) fileIndex++;
        if (eXi && fileIndex > 0) Serial.printf("
 Showing Slide: [%i] %s\n", fileIndex, realName.c_str());
        if (chromeHack) fileIndex++;

        currentSlide = realName;

        // Write out this "frame" to the web client..
        client.write(frameType, ctLen);
        sprintf(hbuff, "
%d\r\n\r\n", imgSize);
        client.write(hbuff, strlen(hbuff));
        client.write(file);
        client.write(boundaryMark, bmLen);

        showTime = millis();
      }

      // Open next file and get details for next loop..
      file = root.openNextFile();
    }

    // Back to main processing loop() when not showing a slide..
    loop();
  }

  // That +1 finally syncs up..
  if (chromeHack && eXi) Serial.printf("
 Showing Slide: [%i] %s\n", fileIndex, realName.c_str());
  delay(slideTime * 1000);

  sShowPlaying = false;
  sssPause = false;
  sssSkip = 0;
}



/*
  Streaming SlideShow Background Control..

  You can also use the Serial command mechanism for this (as "
skip" is a valid serial command),
  like so:

    /skip100

  or

    /skip 100

                                            */

/*
/skip?skip=100    */

void handleSSSSkip() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) { sendEjected(); return; }
  if (!sShowPlaying) { sendNotPlaying(); return; }

  String skip;

  if (server.hasArg("
skip")) {
    skip = server.arg("
start");
    skip.trim();
    uint32_t skipInt = skip.toInt();
    sssSkip = skipInt;
  }

  server.send(200, _TEXT_, "
OK\nStreaming SlideShow Skip: " + (String)sssSkip); // for debugging
}


/*
  We could let the serial mechanism deal with this ("
sp" to toggle the pause state from the console),
  but it's nice to get a proper response in the web.

/sp                  */

void handleSSSPause() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) { sendEjected(); return; }
  if (!sShowPlaying) { sendNotPlaying(); return; }
  sssPause = sssPause ? false : true;
  server.send(200, _TEXT_, "
OK\nStreaming SlideShow : " + (String)(sssPause == true) ? "Paused" : "Playing");
}



/*
  Spit out an image to the browser..

/some.jpg            */

bool loadImage(String path, bool ignoreCashing = false) {

  if (path == "
") return false;
  File imgFile = SD_MMC.open(path.c_str());

  // Yeah, we should probably do two error messages. This one after checking for existence, and
  // another if there was a problem opening the file. But, meh.
  if (!imgFile) {
    String ret = "
Problem opening " + path;
    server.send(500, _TEXT_, ret.c_str());
    return false;
  }

  // Send caching headers..
  if (!cacheImages || ignoreCashing) {
    // Always load images afresh..
    server.sendHeader("
Cache-Control", "no-store, no-cache, max-age=0, must-revalidate");
  } else {
    server.sendHeader("
Cache-Control", "private, max-age=" + (String)(60 * cacheTime) ); // Minutes >> Seconds
    // Sending Cache-Control headers disables legacy "Expires" headers.
  }

  if (eXi) Serial.printf("
 Serving Image File: %s\n", path.c_str());
  server.streamFile(imgFile, "
image/jpeg");

  imgFile.close();
  return true;
}



/*
  Serve the image at a particular index..

  /slides?slide=1462

  Not terribly efficient, but handy.

  You can write controllers with HTML/JavaScript and create a SlideShow using this facility.

  Or you may have a browser plug-in which enables you to easily load the "
next" image (I know I do).

  That's the sort of thing this facility is designed to enable.



/slides              */

void handleSlides() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) { sendEjected(); return; }

  String slide, fileName;
  uint32_t slideInt = 1, fileIndex = 0;

  if (server.hasArg("
slide")) {
    slide = server.arg("
slide");
    slide.trim();
    slideInt = slide.toInt();
    if (slideInt == 0) slideInt = 1;
  }

  File root = SD_MMC.open("
/");
  if ( !root || !root.isDirectory()) {
    server.send(500, _TEXT_, "
 Problem with root directory. Check your SD Card.");
    return;
  }

  // Fast Forward to starting number..
  while (fileIndex < slideInt) {
    fileName = root.getNextFileName();
    if (fileName == "
") { handleOutOfBounds(); return; }
    if (fileName.indexOf(fileEXT) != -1) fileIndex++;
  }

  currentSlide = fileName;
  if (eXi) Serial.printf("
 Serving Slide No: %i (%s)\n", fileIndex, fileName.c_str());
  loadImage(fileName, true);
}


void handleOutOfBounds() {
  if (eXi) Serial.println("
 HTTP: Requested Slide OUT-OF-BOUNDS!");
  currentSlide = noMoreSlides;
  server.send(500, _TEXT_, currentSlide);
}


// Return which slide is currently playing.
// We GET this with AJAX and display in the title bar and top-left of viewport.
void handleReportCurrentSlide() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  if (currentSlide == noMoreSlides) {
    handleOutOfBounds();
  } else {
    server.send(200, _TEXT_, currentSlide);
  }
}



// Is it Day or is it Night?
// Used for DarkMode AUTO Mode.
//
void getNightTimeStatus() {
  if (!useRealTime) return;
  time_t now = time(NULL);
  struct tm *tm_struct = localtime(&now);
  uint8_t hour = tm_struct->tm_hour;
  nightTime = false;
  if (hour >= nightBegins ||  hour < dayBegins) nightTime = true;
}



/*
   Handle web client requests/actions..
                                        */


/*
  Main page.

  I always try to remember that to your browser or even the Google Spider, a web page is just a
  stream of text. Only to humans does it "
look" like anything.

  When coding for browsers, it's good to develop the ability to quickly flip between human and
  spider, depending on your current state; user or coder.

  NOTE: If you are editing in here, I definitely recommend you switch your editor over to HTML or
  JavaScript syntax highlighting. If your editor can't do that in a couple of clicks or less,
  switch editors. (Java highlighting is a good for general-purpose work, too)

  I realise that what follows is, for some, a complete head-f**k. I have worked with many folk who
  at first, looked at HTML/JavaScript/CSS and their brain just went blank. That's okay. It takes a
  minute to wrap your head around the magic. And it is magical.

  But in almost every case, I have seen that "
Oh Wow!" moment, when they realise that it's all just
  a stream of text that they can edit. Simple; when you know how..

  A lightning guide for MCU coders..

  HTML:         The document structure, aka. The Page. Made up of stuff in "
Tags". They open, they
                close. What what goes between them, folk see. Here is a tag: <strong>I will be
                printed in bold</strong>. Simple. Mostly.

                There are "
block" elements (sections, DIVs, etc.) and there are "inline" elements,
                like basic text is. Inline elements can go inside blocks, but not the other way
                around. HTML5 (the current standard) is powerful and quite forgiving.

                Hit Ctrl+U at any website to see what's going on underneath the hood. Scroll past
                the..

  JavaScript:   Code that your /browser/ runs, so won't tax your ESP32 at all. JavaScript can do
                most anything you can imagine a browser-side scripting language could do, and more;
                usually changes to the page you see, but not always.

                AJAX, which makes all the background communications with the ESP32 server possible,
                is all handled with JavaScript. Without JavaScript, the web interface would be
                boring and *much* less useful.

                The style is C-like, but super-flexible*, and lots of fun to code. It can be found
                between HTML <script></script> tags.

                * Seriously, while it may frustrate at times, at other times you sketch pseudo-code
                and it *just works*. You think, "
I wonder if you can do this?". Yup.

  CSS:          The style of things. The "
C" stands for Cascading, and it is beautiful. Later rules
                override earlier rules.

                An "
id" /must/ be unique. Their CSS declarations begin with a "#". So these styles..

                #foo {
                 color: red;
                 background: yellow;
                }

                ..would ONLY apply to this tag (as there is only one "
foo", being unique)..

                  <a href="
https://example.com/" id="foo">PAPER 6 INK 2!</a>

                A "
class" can apply to any number of elements (stuff inside HTML tags) and their CSS
                declarations begin with "
.".

                CSS lives inside the <style></style> tags.

  On proper websites, CSS and JavaScript usually live in external files, but here we just throw
  everything inside the main HTML document, using the above-mentioned tags.

  Another important concept to remember when serving up web pages is that a web server has no
  concept of history. It has zero memory of your previous visits, unless you GIVE it a memory.

  And that's where cookies come in..

                */

void sendRoot() {

  if (eXi) Serial.printf("
 HTTP Request: Main Page for client @ %s\n", server.client().remoteIP().toString().c_str()); // *phew*

  /*
    COOKIES!

    We use cookies to store browser-specific settings like Dark Mode, SlideShow time, etc..

    It's the smart thing to do, and enables us to have different setups on different devices and
    browsers. Each can store its own unique preferences.

    First we add a /final/ delimiter (Cookies come without one) so we can test /against/ it.
    (there's no way to know in which order the cookies will arrive, you see (ask the IETF))
    It's most likely they will arrive in creation order, and in that case, there's NO WAY TO KNOW
    WHICH ORDER THE USER CREATED THEM! So, we always add a delimiter to the the Cookie String.

    NOTE: We use cookie names that will make sense to a user viewing their dev tools window.
          The tiny amount of extra local bytes transferred is well worth it.

                                                            */

  String cookieTest, Cookies = server.header("
Cookie") + ";";


  // Dark Mode..
  darkMode = toBool(cOOkiEChomp(Cookies, "
darkmode", darkModeINIT));
  bgColor = darkMode ? darkBGColor : lightBGColor;

  // Automatic Dark Mode..
  autoDark = toBool(cOOkiEChomp(Cookies, "
autodark", autoDarkINIT));
  getNightTimeStatus();
  if (autoDark) bgColor = nightTime ? darkBGColor : lightBGColor;

  // Store current visual dark state (so we know whether or not to reload)..
  amDark = false;
  if (bgColor == darkBGColor) amDark = true;

  // Sticky PoP-Up Preview..
  stickyPoP = toBool(cOOkiEChomp(Cookies, "
stickypop", stickyPoPINIT));

  // Rotating Load Icon..
  loadingIcon = toBool(cOOkiEChomp(Cookies, "
rotateicon", loadingIconINIT));

  // Loop in PoP view..
  loopPoP = toBool(cOOkiEChomp(Cookies, "
looppop", loopPoPINIT));

  // Spaces in List View..
  spacesInListView = toBool(cOOkiEChomp(Cookies, "
listspaces", spacesInListViewINIT));

  // Image Caching..
  cacheImages = toBool(cOOkiEChomp(Cookies, "
cacheimg", cacheImagesINIT));

  // Image Caching Time..
  cookieTest = cOOkiEChomp(Cookies, "
cachetime", false, "", true, cacheTimeINIT);
  cacheTime = cookieTest.toInt();

  // Stream AND Record simultaneously..
  streamANDRecord = toBool(cOOkiEChomp(Cookies, "
twinstream", streamANDRecordINIT));

  // SlideShow Timer..
  cookieTest = cOOkiEChomp(Cookies, "
slidetime", false, "", true, slideTimeINIT);
  slideTime = cookieTest.toInt();

  // Zoomed Mouse Move Acceleration..
  cookieTest = cOOkiEChomp(Cookies, "
zmaccel", false, "", true, zmAcellerationINIT);
  zmAcelleration = cookieTest.toInt();


  // Background cookies..

  // Seen the tiny stream warning already..
  bool seenTWarn = toBool(cOOkiEChomp(Cookies, "
tinywarn", false, "seen"));
  // Seen the prefs take effect immediately warning already..
  bool seenPWarn = toBool(cOOkiEChomp(Cookies, "
prefwarn", false, "seen"));



  // Gather GET Arguments..
  // The first argument is sent with a question mark (?foo=bar), subsequent arguments use "&" (?foo=bar&bar=foo&etc.)

  displayFrom = 1;
  thumbView = false;
  doPoP = false;
  imagesPerPage = INITimagesPerPage; // reset back to hard-coded defaults
  thumbWidth = INITthumbWidth;

  bool openWithPrefs = false;
  bool openWithSlides = false;
  bool openWithHelp = false;
  bool autoPlaying = false;
  bool amStreaming = false;
  String floatX = "
", floatY = "";
  bool floating = false;
  bool fitMode = true;

  // Some of these we could just set-and-forget, without keeping them in the GET query but it's good
  // to be able to get/bookmark a page with a particular setup, so we mindfully add them back in.
  // See JavaScript->makeGETParams() for the other half of that.

  // We'll fill this in as we go along..
  if (eXi) Serial.printf("
 URI: %s", server.uri().c_str());

  // Iterate the GET arguments..
  for (uint8_t i = 0; i < server.args(); i++) {

    if (server.argName(i) == "
start") displayFrom = server.arg(i).toInt();
    if (server.argName(i) == "
thumbs" && server.arg(i) == "true") thumbView = true;
    if (server.argName(i) == "
pop" && server.arg(i) == "true") doPoP = true;
    if (server.argName(i) == "
per-page") imagesPerPage = server.arg(i).toInt();
    if (imagesPerPage > perPageMax) imagesPerPage = INITimagesPerPage;
    if (server.argName(i) == "
width") thumbWidth = server.arg(i).toInt();

    // These are "states" which are remembered on page refresh/reload..
    if (server.argName(i) == "
prefs") openWithPrefs = true;
    if (server.argName(i) == "
help") openWithHelp = true;

    if (server.argName(i) == "
slideshow") openWithSlides = true;
    if (server.argName(i) == "
play") autoPlaying = true;

    if (server.argName(i) == "
live") amStreaming = true;
    if (server.argName(i) == "
floating") floating = true;
    if (server.argName(i) == "
floatX") floatX = server.arg(i);
    if (server.argName(i) == "
floatY") floatY = server.arg(i);
    if (server.argName(i) == "
fit" && server.arg(i) == "false") fitMode = false;

    // Print ARGS out to Serial line..
    if (eXi) Serial.printf("
%s%s=%s", (i == 0) ? "?" : "&", server.argName(i).c_str(), server.arg(i).c_str());
  }
  if (eXi) Serial.println();
    // Print Cookies..
  if (eXi && Cookies.indexOf("
=") != -1) Serial.printf(" Cookies: %s\n", Cookies.c_str()); //debug

/*
  It's not sensible to use an external HTML file for this because so much of it is generated
  dynamically, based on user input.

  So if you want to change the look and feel of TL-CAM, /here/ is where you need to be.

  We use a mix of CSS and JavaScript to adapt the display to the user's browser and settings. TL-CAM
  will adapt the page to fit the current thumbnail size using a variety of tricks. Most things are
  documented. You'll figure it out.

  But if not, mail me and I'll help you get it working exactly the way you want.

  I have tried to imagine what MCU coders might think, looking at this, and where things get opaque,
  I have commented thoroughly. But if there are things that don't make sense that you want to
  understand and hack; again, just mail me. And thanks; I'll improve the comments there.

  Hit Ctrl+U in your browser to see the source, or better yet, F12 (or whatever) to bring up the Dev
  Tools; which is built-in to all good desktop browsers. On Android, only Kiwi Browser has this
  capability. Syat. All sorts of crazy-useful functionality is hidden in there**.

  En-joy!

  ;o)

  ps. I don't know about you, but in my editor (Kate + plugins) there is a wee coloured box next to
  all the HTML color values which I can click to get a color chooser for editing. When editing
  styles, I find this invaluable. If you don't have this in your editor; investigate!

  ** For example, select a variable in the dev tools sources view, right-click it.
     Now select "
Evaluate selected text in console" to see the *current* value of that variable.
     And much, much more.

 */


  // We build our web page into this String..
  String webPage;

  // Set aside some memory for the web page String..
  webPage.reserve(150000 +  memReserve); // page + thumbs
  // if (eXi) Serial.printf(" Reserved %i bytes for webPage\n", 150000 +  memReserve); //debug

  String author = "
https://corz.org/ESP32/time-lapse-camera-server/";
  webPage.concat(R"
HTML5(<!DOCTYPE html><html>
<head><title>TL-CAM:</title>
<meta name="author" content=")HTML5");
  webPage.concat(author);
  webPage.concat(R"HTML5(">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1">
<style>
:root {
  --bg-color:)HTML5"); // A CSS variable we will re-use.
  webPage.concat(bgColor); // Setup Dark Mode (or not).
  webPage.concat(R"
HTML5(;
  --tl-green: #4CAF50;
  --br-green: #6ffe71;
}
html {
  color: #5caf3e;
  margin: 0;
  background-color: var(--bg-color);
  width: 100% !important;
  font-family: Consolas, ProFontWindows, "Lucida Console", Inconsolata, "Courier New", Courier, monospace;
  font-size: 120%;
}
div {
  margin: 0;
}
button {
  font-family: Consolas, ProFontWindows, "Lucida Console", Inconsolata, "Courier New", Courier, monospace;
}
.clear, .clear-tiny, .clear-small, .clear-med {
  clear: both;
}
.clear-tiny {
  height: 0.1em;
}
.clear-small {
  height: 0.25em;
}
.clear-med {
  height: 0.5em;
}
.red {
  color: #f00;
}
:link, .c-button, .thumbbox, .filelist {
    transition: all 125ms ease 0s;
    color: var(--tl-green);
    text-decoration: none;
}
:visited {
    color: #5dd85d;
    text-decoration: none;
}
a:hover {
    color: #387f38;
    text-decoration: underline;
}
a:active {
    color: var(--br-green);
    text-decoration: none;
}
:focus {
  outline: 0;
}
.small {
  font-size: 80%;
}
#controller {
  clear: both;
  display: block;
  padding-bottom: 0.25em;
  z-index: 20;
}
.c-button, .delbutt, #per-page, #thumb-width, #previousSlide, #nextSlide, .p-select {
  border-radius: 0.1em;
  border: none;
  text-align: center;
  font-weight: bold;
}
.c-button, #per-page, #thumb-width, .p-select {
  color: var(--bg-color);
  background-color: var(--tl-green);
}
.c-button, #per-page, #thumb-width {
  vertical-align: middle; /* required to line up controllers */
  padding: 0.1em 0.2em;
  font-size: 1.5em;
  width: 1.33em;
  height: 1.33em;
  margin: 0 0.25em 0.25em 0;
}

.c-button:hover, #per-page:hover, #thumb-width:hover, #previousSlide:hover,
                #nextSlide:hover, .p-select:hover, #fileUploadLabel:hover {
  background-color: #00778f;
}
.c-button:active, #per-page:active, #thumb-width:active, #previousSlide:active, #nextSlide:active, .p-select:active, #fileUploadLabel:active {
  background-color: var(--bg-color);
  color: #8cacb3;
}
.t-button {
  width: 5em;
  margin: 0 0.2em 0.2em 0;
  font-size: 1em;
}
#DLButt a, DLButt a:visited {
  color: var(--bg-color);
}
#DLButt a:active {
  color: #387f38;
}
#per-page, #thumb-width {
  padding-left: 1em;
}
#per-page > option, #thumb-width > option {
  padding-left: 0;
}
#snap-butt, #live-stream, #stream-record {
  line-height: 94%;
}
#reloading {
  display: block;
}


/* file Uploads */
input[type="file"] {
  position: fixed;
  right: 100%;
  bottom: 100%;
}
#fileUploadLabel {
  border: none;
  display: inline-block;
  border-radius: 0.1em;
  padding: 0.5em;
  text-align: center;
  font-weight: bold;
  color: var(--bg-color);
  background-color: var(--tl-green);
}

)HTML5");

  if (roundedCorners) {
    webPage.concat(R"
HTML5(
.thumbbox { border-radius: 0.25em; }
.thumb { border-radius: 0.5em; }
.delbutt { border-radius: 0.15em; }
#PoP, #slideshow img, #pref-box, #help-box { border-radius: 1em;  }
)HTML5");
  }
  webPage.concat(R"
HTML5(
.thumbbox, #end {
  position: relative;
  float: left;
}
.thumb {
  border: 0;
  padding: 0.1em 0.5em 0.5em 0.5em;
}
#PoP {
  position: fixed;
  border: 0.5em solid var(--bg-color);
  z-index: 1;
  opacity: 0;
  visibility: hidden;
}
.filename {
  padding-left: 0.75em;
  font-size: 0.75em;
}
.delbutt {
  position: relative;
  top: 0.2em;
  width: 1.1em;
  height: 1.2em;
  margin-top: -0.2em;
  padding: 0;
  font-size: 1em;
  background-color: transparent;
  color: var(--tl-green);
}
.delbutt {
  float: right;
  right: 0.15em;
  cursor: crosshair;
}
.datefoot {
  position: absolute;
  font-size: 0.625em;
  bottom: 1.25em;
  left: 1em;
  color: var(--br-green);
}
.list-wide-hide, .list-hide {
  display: none;
  margin: 0;
  padding: 0;
}
#slideshow, #preferences, #livestream, #help {
  transition: all 125ms ease 0s;
  display: block;
  opacity: 0;
  visibility: hidden;
  height: 100vh;
  width: 100vw;
  background-color: rgba(9,21,10,.98);
  z-index: 500;
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
}
#preferences {
  font-size: 78%;
  background-color: rgba(9,21,10,.80);
  z-index: 600;
}
#livestream {
  z-index: 700;
  background-color: rgba(9,21,10,.66);
}
#help {
  z-index: 900;
}
@keyframes recording {
  0%, 100% { opacity: 0.1; }
  50% { opacity: 1; }
}
#dot {
  border-radius: 50%;
  position: absolute;
  background-color: red;
  bottom: 0.15em;
  right: 0.15em;
  width: 1.5em;
  height: 1.5em;
  animation: recording 1.5s infinite;
  z-index: 900;
}
/* we could just slam it into main <body> but this is neater */
#dot-holder {
  width: 2em;
  height: 2em;
  position: fixed;
  bottom: 0.25em;
  right: 0.3em;
  z-index: 890;
}
#prefs-message {
  position: fixed;
  color: var(--br-green);
  /*font-size: 120%;*/
  font-weight: 700;
  top: 0.4em;
  left: 1%;
  width: 99%;
  text-align: center;
  white-space: pre;
}
#pref-box, #help-box {
  margin: 0;
  background: var(--bg-color);
  overflow: scroll;
  position: absolute;
  bottom: 0.5em; /* pin to bottom */
  left: 50%;
  transform: translateX(-50%);
  padding-top: 0.5em;
  scrollbar-width: none; /* Firefox */
}
#pref-box {
  height: 87vh;
  width: 81vw;
}
#help-box {
  height: 90vh;
  width: 94vw;
  padding: 1em;
  white-space: pre;
  overflow: overlay;
  z-index: 990;
}

/* only applicable on tiny devices, and they will naturally /swipe/ */
#pref-box::-webkit-scrollbar {
  display: none;
}
.pref-group {
  font-size: 115%;
  font-weight: bold;
  width: 68vw;
  display: inline-block;
  margin: 0.5em 0.5em 0 1em;
}
.pref-title {
  font-size: 120%;
  margin-bottom: 0.25em;
}
/* wee hack to better line-up the checkboxes and selects with their labels */
.pref-label {
  padding-top: 0.1em;
  display: block;
}
.pref-label input[type=checkbox], .p-select {
  margin-top: -0.1em;
}
.p-select {
  padding: 0.05em 0.25em;
  width: 3.33em;
  float: right;
  text-align: left;
  font-size: 110%;
}
input[type=checkbox] {
  float: right;
  margin-right: 0;
  border-radius: .3em;
  height: 1.5em;
  width: 1.5em;
  accent-color: var(--tl-green);
}
input[type=range]  {
  accent-color: var(--tl-green);
  margin: 0;
  width: 100%
}
#slide {
  height: calc(100vh - 1em);
  margin: 0;
  position: absolute;
  background: transparent;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border: 0.5em solid var(--bg-color);
  z-index: 550;
  transition: none;
}
#slide-name {
  position: absolute;
  top: 0.5em;
  left: 1em;
  color: var(--br-green);
  z-index: 560;
}
#previousSlide, #nextSlide {
  color: var(--bg-color);
  background-color: var(--tl-green);
  width: 1.25em;
  font-size: 600%;
  margin: 0;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}
#previousSlide {
  left: 0.25em;
}
#nextSlide {
  right: 0.25em;
}
#link, #version {
  font-weight: bold;
  position: fixed;
  bottom: 0.1em;
  z-index: 999;
}
#link {
  right: 0.4em;
  font-size: 72%;
}

#version {
  left: 0.5em;
  font-size: 90%;
}
#message {
  display: inline-block;
  clear: both;
  width: 100%;
  margin: 0.25em auto 0.1em;
  font-size: 140%;
  font-weight: 700;
}
#getInterval {
  cursor: crosshair;
}
.warning {
  color: #ff1c07 !important;
}
)HTML5");
/*
   Touch mode PoP-link-indicator.
   When swiping back and forth on PoP to switch images, the current image link is highlighted with this class.
*/

  webPage.concat(R"
HTML5(
.pop-highlight, .filelist:hover a, .thumbbox:hover {
  background-color: #ec6c16; /* #ec8217 */
}
/* hover anywhere in the list view link div to highlight /only/ the file name. */
.pop-highlight, .pop-highlight a, .thumbbox:hover a, .filelist:hover a, .thumbbox:hover {
  color: #000;
}
#thumbnails {
  margin-top: 0.2em;
}
#listing {
  margin: 0 auto 0 0.25em;
 /* display: flex;
  flex-direction: column; */

  width: fit-content; /* So PoP can fill the entire space AND we can float .listdelbutt right against it. */
}
.listdelbutt {
  background-color: transparent;
  border: none;
  padding: 0.15em 0 0 0.1em;
  color: var(--tl-green);
  font-size: 90%;
  float: right; /* so delete buttons line up - you can just delete delete delete, without moving your pointer */
}
/* PoP hover is on the <div>, for seamless gliding */
#listing .filelist a {
  line-height: 1.5em;
}
)HTML5");


/*
  This is our fall-back for non-level-4 CSS capable browsers.

  The following funky CSS (adjacent sibling selector, the "
+") creates a big red border around the
  thumbnail image when you hover over the delete buttons. Ugly-ish, but functional as a shovel.
  I'll leave this in here for now, in case you don't yet have a level-4 capable web browser.
  In List View, only the [x] turns red. See below.
*/

  webPage.concat(R"
HTML5(
.delbutt:hover + a > img, .listdelbutt:hover {
  background: #f00 !important;
  opacity: 0.80;
}
)HTML5");

/*
  This level 4 CSS creates a red background for the /entire/ thumbnail box when hovering over a
  /child/ element (the delete button) and highlights the /entire/ parent + kids. Very nice!
  Assuming you aren't in Firefox (real soon now).

  This also effortlessly enables full-row delete warning highlights in List View mode (2nd rule).

  NOTE: In list view mode, when you hover over the delete button, as well as highlighting the entire
  row, we de-highlight the regular hover/select highlighting style in the file link (3rd rule), so
  there is a single style instead of two.

*/

  webPage.concat(R"
HTML5(
.thumbbox:has(> .delbutt:hover), .filelist:has(> .listdelbutt:hover), a:has(~ .listdelbutt:hover) {
  background: #f00 !important;
  opacity: 0.80;
}
)HTML5");
/*
   If you desperately want full-line highlighting in less capable browsers, you can add
   "
, .listdelbutt:hover ~ a" to the main selector, and then do something like this..

.filelist {
  display: flex;
}
.filelist a {
  order: -1;
  transition: none;
}
  Which basically switches around the visual order of the link and delete button. Then in your
  markup, make it oppositely so: move the <button> to immediately /before/ the <a> tag. Done.

  But note, you will lose the global right-side line-up, for easy gliding over delete buttons, which
  is really only an issue where the file names / index numbers change size. Mostly it will work
  exactly like the fancy CSS4 version. I guess you could do a similar thing for thumbnails.
*/

  webPage.concat(R"
HTML5(
.pad-left {
  padding-left: 0.2em;
}
)HTML5");

/*
   We move things around and add features for larger displays; which I definitely
   recommend you use to interact with TL-CAM. Something with minimum 800px wide
   and an accurate mouse/stylus/pointer, is ideal.

   Sure, this layout is "
responsive", but there's only so much we can see and do in a small screen.

   What follows here are changes to the basic CSS when you use (or resize to) a *bigger* screen..

*/

webPage.concat(R"
HTML5(
/* Collapsing thumb information.. */
@media screen and (min-width: 600px) {
  .list-wide-hide {
    display: inline;
  }
}
/* As the size increases, we add information.. */
@media screen and (min-width: 720px) {
  .list-hide {
    display: inline;
  }
  .list-wide-hide {
    display: inline;
  }
}
/* Main switch */
@media screen and (min-width: 891px) {

  html {
   font-size: 90%;
  }
  #controller {
    padding-bottom: 0.9em;
  }
  #preferences {
    font-size: 80%;
  }
  #per-page, #thumb-width {
    font-size: 1.5em;
  }
  .filename {
    padding-top: 0;
  }
  #message {
    display: inline;
    position: relative;
    top: 0.25em;
    clear: none;
    width: auto;
    padding: 0 0 0 0.66em;
  }
  #pref-box {
    height: 75vh;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
  .pref-group {
    vertical-align: top;
    font-size: 150%;
    width: 17rem;
  }
  #prefs-message {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    font-size: 140%;
  }
  .datefoot {
    font-size: 0.77em;
    bottom: 1em;
    left: 0.9em;
  }
}
</style></head><body>
)HTML5");

  // Controller buttons..
  webPage.concat("
<div id=\"controller\">");

  // Previous / Next Pages..
  String bottomEnd = (String)(displayFrom - imagesPerPage);
  if (displayFrom < imagesPerPage) bottomEnd = "
1";
  String doPrevious = "
Previous Page";
  if (displayFrom == 1) doPrevious = "
THIS PAGE!";


  // Previous..
  webPage.concat("
<button id=\"previousPage\" onclick=\"goPage(" + bottomEnd + ");\" ");
  webPage.concat("
class=\"c-button\" title=\"" + doPrevious + " (");
  webPage.concat(bottomEnd + "-" + (String)( (displayFrom < imagesPerPage) ? imagesPerPage : displayFrom - 1) );
  webPage.concat(") HotKey: [\">&lt;</button>");

  // Home..
  webPage.concat("
<button class=\"c-button\" title=\"home\" id=\"homebutt\" onclick=\"goHome()\">/</button>");

  // Reload
  webPage.concat("
<button class=\"c-button\" title=\"reload the page\n");
  webPage.concat("this icon moonlights as an indicator for background pre-loading images\" id=\"reload\"");
  webPage.concat("
 onclick=\"location.reload()\"><span id=\"reloading\">&#8635;</span></button>");

  // SELECT: Images-per-page selector..
  webPage.concat("
<select onchange=\"setMessage('" + loadingMsg + "'); setPerPage(this.value)\" ");
  webPage.concat("
title=\"images-per-page chooser\" name=\"per-page\" id=\"per-page\">");
  webPage.concat("
  <option value=\"" + (String)imagesPerPage + "\">images per page: &nbsp;</option>");
  // We use CSS to push the entries out of view, for neat button controls.
  for (uint16_t pp = INITimagesPerPage; pp <= perPageMax; pp += perPageMenuStep) {
    webPage.concat("
<option value=\"" + (String)pp + "\"");
    if (pp == imagesPerPage) webPage.concat("
 selected");
    webPage.concat("
>" + (String)pp + "</option>");
  }
  webPage.concat("
</select>");


  // Next..
  String nextImage = (String)(displayFrom + imagesPerPage);
  webPage.concat("
<button id=\"nextPage\" onclick=\"goPage(" + nextImage + ");\" ");
  webPage.concat("
class=\"c-button\" title=\"next page (" + nextImage + "-");
  webPage.concat((String)( displayFrom + ( imagesPerPage * 2 ) - 1 ) + ") HotKey: ]\">&gt;</button>");

  // Thumbnails / File list toggle..
  webPage.concat("
<button class=\"c-button\" id=\"thumbsbutt\" onclick=\"toggleThumbs()\"");
  webPage.concat((thumbView ? "
title=\"switch to list view (T)\n(yes, it's the Trigram for Heaven) \">&#9776;</button>" : \
    "
title=\"switch to thumbnail view (T)\">&#9723</button>") );

  // Thumbnail Width..
  if (thumbView) {
    webPage.concat("
<select onchange=\"setMessage('" + loadingMsg + "'); setThumbWidth(this.value)\" ");
    webPage.concat("
title=\"thumbnail width chooser (in actual fact, this is a misnomer as we actually set\n");
    webPage.concat("the *height* to 3/4 of whatever you set here, so the thumbnails flow properly)\n");
    webPage.concat("regular 4:3 images will scale perfectly - other ratios will scale to fit at whatever width \" ");
    webPage.concat("
name=\"thumb-width\" id=\"thumb-width\">");
    webPage.concat("
  <option value=\"" + (String)thumbWidth + "\">Thumbnail width: &nbsp;</option>");

    for (uint16_t tw = 50; tw <= 400; tw += 25) {
      webPage.concat("
<option value=\"" + (String)tw + "\"");
      if (tw == thumbWidth) webPage.concat("
 selected");
      webPage.concat("
>" + (String)tw + "</option>");
    }
    webPage.concat("
</select>");
  }

  // Pop-up preview toggle button..
  webPage.concat("
<button class=\"c-button\" id=\"popbutt\" onclick='togglePoP()' ");
  webPage.concat((doPoP ? "
title=\"disable pop-up previews (P)\">-</button>" : \
    "
&pop=true'\" title=\"enable pop-up previews (P) \">+</button>") );

   // Take a snap..
  webPage.concat("<button class=\"c-button\" title=\"take a snap right now! (.)\" ");
  webPage.concat("id=\"snap-butt\" onclick=\"takeSnap()\">&#8727;</button>");

  // Live Stream
  webPage.concat("<button class=\"c-button\" title=\"click to toggle live stream preview (L)\" id=\"live-stream\"");
  webPage.concat(" onclick=\"streamOpen ? closeLiveStream() : openLiveStream();\">&#x25B8;</button>");

  // Record Stream
  webPage.concat("<button class=\"c-button\" title=\"click to toggle live stream recording (')\" ");
  webPage.concat("id=\"stream-record\" onclick=\"toggleRecording();\">");
  if (amRecording) { webPage.concat("&#x23F9;"); } else { webPage.concat("&#x25CF;"); }
  webPage.concat("</button>");

   // Prefs/Settings..
  webPage.concat("<button class=\"c-button\" title=\"settings / preferences (,)\" ");
  webPage.concat("onclick=\"openPrefs()\">&#9881;</button>");  // gear/cog
  /*
    In Kiwi Browser (which is, as far as I know, the only Android web browser with dev tools
    enabled), in fact, in ALL Android browsers (so, not Kiwi's fault, I just wanted to give it a
    free plug, cuz how handy is that!) this cog will render as a 3-dimensional, shaded cog-thing.
    I mean WTF! Android, get your shit together. A glyph is a glyph. Don't mess with it.
   */



  // Default "message" title.
  webPage.concat("\n<div id=\"message\"");
  if (thumbView || doPoP) {
    webPage.concat(" title=\"click me to start a slideshow (ctrl+click to go fullscreen)\n");
    webPage.concat("
note: spacebar activates an auto-playing slideshow - add Ctrl to go fullscreen. \"");
  } else {
    webPage.concat("title=\"in thumbnail view, or when Pop-Up previews are enabled, click here to start a slideshow! \"");
  }
  webPage.concat(">" + loadingMsg + "</div>");


  // End controller
  webPage.concat("</div>\n");


  // Image links, either thumbnails or a file list..
  if (thumbView) {
    webPage.concat("<div id=\"thumbnails\">\n");
  } else {
    webPage.concat("<div id=\"listing\">\n");
  }

  // The actual listing..
  webPage.concat( listDir(false, true) );

  // A dummy div we use to calculate where the bottom of the thumbnails ended up in your viewport.
  if (thumbView) webPage.concat("<p class=\"thumbbox\" id=\"end\"></p>\n");

  // Close the file list / thumbnails div..
  webPage.concat("</div>\n");


  // Pop-Up-On-Hover Preview Image. It lives to avoid your pointer.
  if (doPoP) webPage.concat("<img id=\"PoP\" alt=\"Popup Preview Image\" />\n");


  /*
  SlideShow Markup..    */

  if (doPoP || thumbView) {
    webPage.concat("<div id=\"slideshow\">");
    webPage.concat("<button id=\"previousSlide\" alt=\"Previous\" ");
    webPage.concat("title=\"previous slide\" onclick=\"userPreviousSlide()\">&lt;</button>");
    webPage.concat("<a id=\"imgSaveLink\" title=\"'s' or &lt;click&gt; to save the image\"><img id=\"slide\" src=\"\" /></a>");
    webPage.concat("<button id=\"nextSlide\" alt=\"Next\" title=\"next slide\" onclick=\"userNextSlide()\">&gt;</button>");
    webPage.concat("<div id=\"slide-name\" title=\"current slide\" ></div>");
    webPage.concat("</div>\n");
  }


  /*
  Live Video Preview Stream..    */

  webPage.concat("<div id=\"livestream\"></div>\n");

  // Recording indicator goes here when live stream preview isn't running..
  webPage.concat("<div id=\"dot-holder\"></div>\n");



  /*
     Preferences Panel..
                          */


  // Main full-screen overlay..
  webPage.concat("<div id=\"preferences\">\n");


  // Source Link only appears in prefs panel..
  webPage.concat("<div id=\"link\"><a href=\"https://corz.org/ESP32/time-lapse-camera-server/\" ");
  webPage.concat("title=\"A source of ESP32 goodies, and much, much more..\" target=\"_blank\">TL-CAM</a></div>");

  // Version information..
  webPage.concat("<div id=\"version\"><span style=\"padding-right:0.2em\">v</span>" + version + "</div>");

  // Server responses, messages, etc..
  webPage.concat("<div id=\"prefs-message\"></div>\n");

  // The containing "box"..
  webPage.concat("<div id=\"pref-box\">\n");

  /*
      Now the individual "blocks" of settings..

      It's easy enough for you to add controls for whatever you like.
      Put a section here. Another in the handleCameraSettings() function to process it,
      and finish with a command in gatherPrefs(), to load the preference at boot-up, if required.
  */


  /*
      Camera Server Settings..
                                */


  webPage.concat("<div class=\"pref-group\">\n<div class=\"pref-title\">Camera Server:</div>");
  webPage.concat("<div class=\"clear-small\"></div>\n");

  // Required for Firefox (or else inputs don't fire)..
  webPage.concat("<form name=\"firefox-again\" autoComplete=\"off\" onsubmit=\"return false\">\n");


  // CHECKBOX: Cache Images..
  webPage.concat("<label class=\"pref-label\" ");
  webPage.concat("title=\"save stress on your ESP32 when browsing and watching slideshows \n");
  webPage.concat("
note: an image's cache time is set at the moment the image is cached by the browser ");
  webPage.concat("
this setting has no effect on images already cached (Ctrl+F5 for total refresh)\">Cache Images:");
  webPage.concat("<input type=\"checkbox\" onchange=\"boolSetting(this, 'cai')\"");
  if (cacheImages) webPage.concat(" checked");
  webPage.concat(" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // SELECT: Image Cache Time..
  // This format (label container) is probably semantically incorrect, but I think it's neater.
  webPage.concat("<label class=\"pref-label\" title=\"set the image caching time, in minutes\">Cache Time:");
  webPage.concat("<select class=\"p-select\" onchange=\"setTLCAMSettings('cst', this.value)\">");
  for (uint16_t si = 1; si <= 30; si++) {
    webPage.concat("<option value=\"" + (String)si + "\"");
    if ( si == cacheTime ) webPage.concat(" selected");
    webPage.concat(">" + (String)si + "</option>");
  }
  for (uint16_t si = 45; si < 90; si+=15) {
    webPage.concat("<option value=\"" + (String)si + "\"");
    if ( si == cacheTime ) webPage.concat(" selected");
    webPage.concat(">" + (String)si + "</option>");
  }
  for (uint16_t si = 90; si <= 720; si+=30) {
    webPage.concat("<option value=\"" + (String)si + "\"");
    if ( si == cacheTime ) webPage.concat(" selected");
    webPage.concat(">" + (String)si + "</option>");
  }
  webPage.concat("</select></label>\n<div class=\"clear-small\"></div>\n");

  // SELECT: LED Brightness..
  webPage.concat("<label class=\"pref-label\" title=\"set the LED flash brightness (0-255)\n");
  webPage.concat("
the extremely powerful 3030 LED will FLASH momentarily at the new level\nWATCH OUT!\">LED Brightness:");
  webPage.concat("<select class=\"p-select\" onchange=\"setMessage('TEST!');setTLCAMSettings('ledb', this.value)\">");
  for (uint16_t si = 0; si <= 255; si++) {
    webPage.concat("<option value=\"" + (String)si + "\"");
    if ( si == ledBrightness ) webPage.concat(" selected");
    if ( si > 127 ) webPage.concat(" style=\"color: red\"");
    webPage.concat(">" + (String)si + "</option>");
  }
  webPage.concat("</select></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: Timestamp Files..
  webPage.concat("<label class=\"pref-label\" title=\"use timestamps for the image file names\">Timestamp Files:");
  webPage.concat("<input type=\"checkbox\" onchange=\"boolSetting(this, 'tsf')\"");
  if (timeStampFileNames) webPage.concat(" checked");
  webPage.concat(" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // CHECKBOX: Overwrite Files..
  webPage.concat("<label class=\"pref-label\" title=\"overwrite old images with new captures\n");
  webPage.concat("
(only works with numerically-named images, not timestamped images)\">Overwrite Files:");
  webPage.concat("<input type=\"checkbox\" onchange=\"boolSetting(this, 'ovw')\"");
  if (overWrite) webPage.concat(" checked");
  webPage.concat(" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // CHECKBOX: // Stream AND record simultaneously (streamANDRecord)..
  webPage.concat("<label class=\"pref-label\" ");
  webPage.concat("title=\"TL-CAM defaults to either playing OR recording the live stream, but you can have both.. \n");
  webPage.concat("
expect a 5%-ish drop in frame-rates when running both simultaneously - not too bad\n");
  webPage.concat("
note: this setting is saved in a cookie, for each individual browser\">Twin Streaming:");
  webPage.concat("<input type=\"checkbox\" onchange=\"streamANDRecord=(this.checked ? true : false);boolSetting(this, 'tws')\"");
  if (loopPoP) webPage.concat(" checked");
  webPage.concat(" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // SELECT: Live Stream Frame Limiting -- Max Frames-Per-Second..
  webPage.concat("<label class=\"pref-label\" title=\"set the live stream frame limit, in maximum frames-per-second (0-100)\n");
  webPage.concat("
set to 0 to disable frame-limiting (this setting applies to stream viewing and recording)\">Frame Limit (FPS):");
  webPage.concat("<select class=\"p-select\" onchange=\"setTLCAMSettings('mfps', this.value)\">");
  for (uint16_t si = 1; si <= 9; si++) {
    webPage.concat("<option value=\"0." + (String)si + "\"");
    if ( si/10 == max_fps ) webPage.concat(" selected");
    webPage.concat(">0." + (String)si + "</option>");
  }
  for (uint16_t si = 0; si <= 60; si++) {
    webPage.concat("<option value=\"" + (String)si + "\"");
    if ( si == max_fps ) webPage.concat(" selected");
    webPage.concat(">" + (String)si + "</option>");
  }
  webPage.concat("</select></label>\n<div class=\"clear-tiny\"></div>\n");


  // End Server Settings (pref-group)..
  webPage.concat("</div>\n");



  /*
      Web  Settings..
                          */


  webPage.concat("<div class=\"pref-group\">\n<div class=\"pref-title\">Web Settings:</div>");
  webPage.concat("<div class=\"clear-small\"></div>\n");

  // SELECT: SlideShow Time..   (we use the same variable name for C + JS: slideTime)
  webPage.concat("<label class=\"pref-label\" ");
  webPage.concat("title=\"set the slideshow time, in seconds. note: this setting also applies to \n");
  webPage.concat("
title=\"the Streaming SlideShow (you can alter this value while it is playing)\">SlideShow Time:");
  webPage.concat("
<select class=\"p-select\" onchange=\"slideTime=this.value;setTLCAMSettings('sst', this.value)\">");
  for (uint16_t si = 1; si <= 60; si++) { // Feel free to increase this number.
    webPage.concat("
<option value=\"" + (String)si + "\"");
    if ( si == slideTime ) webPage.concat("
 selected");
    webPage.concat("
>" + (String)si + "</option>");
  }
  webPage.concat("
</select></label>\n<div class=\"clear-small\"></div>\n");

  // CHECKBOX: Extra Spaces in List Views..   spacesInListView
  webPage.concat("
<label class=\"pref-label\" ");
  webPage.concat("
title=\"put extra space between the information elements in list view\">Extra Space in Lists:");
  webPage.concat("
<input type=\"checkbox\" onchange=\"setMessage('reloading..');boolSetting(this, 'xsp')\"");
  if (spacesInListView) webPage.concat("
 checked");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: Animated loading icon..   loadingIcon
  webPage.concat("
<label class=\"pref-label\" title=\"rotating loading icon ");
  webPage.concat("let's you know images are pre-loading in the background\">Loading Icon:");
  webPage.concat("
<input type=\"checkbox\" onchange=\"loadingIcon=(this.checked ? true : false);boolSetting(this, 'aii')\"");
  if (loadingIcon) webPage.concat("
 checked");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: Sticky PoP (stickyPoP)
  webPage.concat("
<label class=\"pref-label\" ");
  webPage.concat("
title=\"sticky pop-up preview image - rather than vanish, PoP will 'stick' in place\n");
  webPage.concat("then you can use arrow keys/swipe to navigate and delete/backspace to delete images \">Sticky PoP:");
  webPage.concat("
<input type=\"checkbox\" onchange=\"stickyPoP=(this.checked ? true : false);boolSetting(this, 'skp')\"");
  if (stickyPoP) webPage.concat("
 checked");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: Loop PoP (loopPoP)
  webPage.concat("
<label class=\"pref-label\" ");
  webPage.concat("
title=\"when PoP is 'sticky' you can use arrow keys/swipe to move through your images \n");
  webPage.concat("when you reach the end, TL-CAM can either flash to indicate this or loop around to the other end\">PoP Loops:");
  webPage.concat("
<input type=\"checkbox\" onchange=\"loopPoP=(this.checked ? true : false);boolSetting(this, 'lpp')\"");
  if (loopPoP) webPage.concat("
 checked");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: Dark Mode..
  webPage.concat("
<label class=\"pref-label\" title=\"toggle dark mode\n");
  webPage.concat("like most other interface prefs, this setting is stored in a browser cookie\">Dark Mode:");
  webPage.concat("
<input type=\"checkbox\" id=\"darkmode\" onchange=\"setMessage('reloading..');boolSetting(this, 'dms')\"");
  if (darkMode) webPage.concat("
 checked");
  if (autoDark) webPage.concat("
 disabled");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // CHECKBOX: AUTO Dark Mode..
  webPage.concat("
<label class=\"pref-label\" title=\"Automatic Dark mode switches modes depending on the time of day\n");
  webPage.concat("note: if Real (NTP) Time is disabled, dark mode will never occur.\">Automatic Dark Mode:");
  webPage.concat("
<input type=\"checkbox\" id=\"autodark\" onchange=\"autoDMSetting(this, 'dma')\"");
  if (autoDark) webPage.concat("
 checked");
  webPage.concat("
 /></label>\n<div class=\"clear-tiny\"></div>\n");

  // SLIDER: Mouse Movement Acceleration..
  webPage.concat("
<label class=\"pref-label\" ");
  webPage.concat("
title=\"adjust zoomed-in mouse movement acceleration (1-100: higher is faster)\">Zoomed Mouse Acceleration:");
  webPage.concat("
<input type=\"range\" id=\"acceleration\" min=\"1\" max=\"100\" value=\"");
  webPage.concat(zmAcelleration);
  webPage.concat("\" step=\"1\" onchange=\"setAcceleration(this, 'maz')\" oninput=\"updateSliderLiveTitle(this)\" title=\"");
  webPage.concat(zmAcelleration);
  webPage.concat("\" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // End Web Settings (pref-group)..
  webPage.concat("
</div>\n");



  /*
      Snap Interval Time..

      Radio-Button Drop-Downs!

                              */


  webPage.concat("
<div class=\"pref-group\">\n<div onclick=\"getIntervalTime()\" id=\"getInterval\"");
  webPage.concat("
class=\"pref-title\" title=\"click me to check the current snap interval time\">Snap Interval Time:</div>");

  webPage.concat("
<div class=\"clear-small\"></div>\n"); // I don't do <fieldset>!

  // SELECT: Seconds..
  webPage.concat("
<label class=\"pref-label\" title=\"set the snap interval time, in seconds\" >Seconds:");
  webPage.concat("
<select class=\"p-select\" onchange=\"setSnapInterval(this, this.value, 'second')\" ");
  webPage.concat("
id=\"set-snap-seconds\">\n<option value=\"\"></option>");
  for (uint16_t si = 1; si < 60; si++) {
    webPage.concat("
<option value=\"" + (String)si + "\"");
    if ( si == (delayTime / 1000) ) webPage.concat("
 selected");
    webPage.concat("
>" + (String)si + "</option>");
  }
  webPage.concat("
</select></label>\n<div class=\"clear-tiny\"></div>\n");

  // SELECT: Minutes..
  webPage.concat("
<label class=\"pref-label\" title=\"set the snap interval time, in minutes\">Minutes:");
  webPage.concat("
<select class=\"p-select\" onchange=\"setSnapInterval(this, this.value, 'minute')\" ");
  webPage.concat("
id=\"set-snap-minutes\">\n<option value=\"\"></option>");
  for (uint16_t si = 1; si < 60; si++) {
    webPage.concat("
<option value=\"" + (String)si + "\"");
    if ( si == (delayTime / MINUTE_MILLIS) ) webPage.concat("
 selected");
    webPage.concat("
>" + (String)si + "</option>");
  }
  webPage.concat("
</select></label>\n<div class=\"clear-tiny\"></div>\n");

  // SELECT: Hours..
  webPage.concat("
<label class=\"pref-label\" title=\"set the snap interval time, in hours\">Hours:");
  webPage.concat("
<select class=\"p-select\" onchange=\"setSnapInterval(this, this.value, 'hour')\" ");
  webPage.concat("
id=\"set-snap-hours\">\n<option value=\"\"></option>");
  for (uint16_t si = 1; si < 24; si++) {
    webPage.concat("
<option value=\"" + (String)si + "\"");
    if ( si == (delayTime / HOUR_MILLIS) ) webPage.concat("
 selected");
    webPage.concat("
>" + (String)si + "</option>");
  }
  webPage.concat("
</select></label>\n<div class=\"clear-tiny\"></div>\n");

  // SELECT: Days..
  webPage.concat("
<label class=\"pref-label\" title=\"set the snap interval time, in days\">Days:");
  webPage.concat("
<select class=\"p-select\" onchange=\"setSnapInterval(this, this.value, 'day')\" ");
  webPage.concat("
id=\"set-snap-days\">\n<option value=\"\"></option>");
  for (uint16_t si = 1; si <= 365; si++) {
    webPage.concat("
<option value=\"" + (String)si + "\"");
    if ( si == (delayTime / DAY_MILLIS) ) webPage.concat("
 selected");
    webPage.concat("
>" + (String)si + "</option>");
  }
  webPage.concat("
</select></label>\n<div class=\"clear-small\"></div>\n");

  // CHECKBOX: Web Snap Resets Timer..
  webPage.concat("
<label class=\"pref-label\" ");
  webPage.concat("
title=\"web 'snap' button resets the main capture timer - or not\n");
  webPage.concat("title=\"this setting is saved to NVS\">Web Snap Resets Timer:");
  webPage.concat("<input type=\"checkbox\" onchange=\"boolSetting(this, 'wsr')\"");
  if (webSnapResetsTimer) webPage.concat(" checked");
  webPage.concat(" /></label>\n<div class=\"clear-tiny\"></div>\n");


  // End snap interval (pref-group)..
  webPage.concat("</div>\n");



  // Extra commands..

  webPage.concat("<div class=\"pref-group\">\n<div class=\"pref-title\">Extras:</div>");
  webPage.concat("<div class=\"clear-small\"></div>\n");

  // PUSHBUTTON: eject SD
  webPage.concat("<button class=\"c-button t-button\" title=\"eject the SD card\"");
  webPage.concat(" onclick=\"doSDFunctions('eject')\">Eject SD</button>");

  // PUSHBUTTON: insert SD
  webPage.concat("<button class=\"c-button t-button\" title=\"restart the SD card after being ejected\"");
  webPage.concat(" onclick=\"doSDFunctions('insert')\">Start SD</button>");

  // PUSHBUTTON: SD Card space info
  webPage.concat("<button class=\"c-button t-button\" title=\"get SD card space info\"");
  webPage.concat(" onclick=\"doGetInfo()\">SD Info</button>");

  // PUSHBUTTON: memory info
  webPage.concat("<button class=\"c-button t-button\" title=\"ESP32 memory information\"");
  webPage.concat(" onclick=\"doGetMem()\">Memory</button>");

  // // PUSHBUTTON: LIVE Stream
  // webPage.concat("<button class=\"c-button t-button\" title=\"View a live stream from the camera (L)\"");
  // webPage.concat(" onclick=\"openLiveStream()\">Live</button>");


  // PUSHBUTTON: Backup camera config to NVS
  webPage.concat("<button class=\"c-button t-button\" title=\"backup your current camera sensor configuration to NVS\"");
  webPage.concat(" id=\"backup-butt\" onclick=\"doNVSFunctions('backup')\">Backup</button>");

  // PUSHBUTTON: Restore camera config from NVS
  webPage.concat("<button class=\"c-button t-button\" title=\"restore the backup camera sensor configuration from NVS\"");
  webPage.concat(" id=\"restore-butt\" onclick=\"doNVSFunctions('restore')\">Restore</button>");

  // PUSHBUTTON: Download the recorded video stream (if there is one)..
  webPage.concat("<button class=\"c-button t-button\" id=\"DLButt\" title=\"Download recorded video stream (if available)\"");
  webPage.concat("\"><a download=\"" + streamFileName + "\" href=\"download\">DL Video</a></button>");

  // End Extras Prefs Group..
  webPage.concat("
\n<div class=\"clear-tiny\"></div>\n");
  webPage.concat("
</div>\n");



  /*
     File Upload
                    */


  webPage.concat("
<div class=\"pref-group\">\n");
  webPage.concat("
<div class=\"pref-title\">File Upload:</div>");
  webPage.concat("
<div class=\"clear-small\"></div>\n");

  webPage.concat("
<label for=\"fileUpload\" id=\"fileUploadLabel\">");
  webPage.concat("
<i class=\"upload\"></i>Upload a File</label>");
  webPage.concat("
<input id=\"fileUpload\" type=\"file\"/>");
  webPage.concat("
</div>\n");


  // Small space at the foot..
  webPage.concat("
<div class=\"clear-med\"></div>\n");

  // End pref-box..
  webPage.concat("
</div>\n");


  // End "preferences" overlay
  webPage.concat("
</div>\n");
  webPage.concat("
</form>\n");


  webPage.concat("
<div id=\"help\"><div id=\"help-box\">");

  webPage.concat("
<h2>TL-CAM Web Help</h2>");

  webPage.concat("
<h3>Main HotKeys</h3>");

  webPage.concat("
  [       Previous Page\n");
  webPage.concat("
  ]       Next Page\n");
  webPage.concat("
  ,       Preferences Panel (comma)\n");
  webPage.concat("
  T       Toggle Thumbnails / List View\n");
  webPage.concat("
  P       Toggle Pop-Up Preview\n");
  webPage.concat("
  SPACE   Open and Play SlideShow\n");
  webPage.concat("
  L       Toggle Live Stream View\n");
  webPage.concat("
  '       Record Live Stream Start / Stop (apostrophe)\n");
  webPage.concat("
  R       Rotate Live Stream\n");
  webPage.concat("
  .       Capture image immediately (full-stop, aka. 'point')\n");

  webPage.concat("
\n");
  webPage.concat("
<h3>SlideShow HotKeys</h3>");

  webPage.concat("
  UP / LEFT Arrows      Previous Slide\n");
  webPage.concat("
  DOWN / RIGHT Arrows   Next Slide (up and down can be reversed in your prefs)\n");
  webPage.concat("
  S                     Save current image to browser device\n");
  webPage.concat("
  SPACE                 Start / Stop playing SlideShow\n");
  webPage.concat("
  Enter                 Exit SlideShow\n");
  webPage.concat("
  F                     Toggle Fast As F**k Mode\n");
  webPage.concat("
  UP / DOWN Arrows      During Fast As F**k Mode, increase/decrease delay (by 25ms)\n");

  webPage.concat("
</div></div>\n");


  if (doPoP && !thumbView) webPage.concat("
<div id=\"preloaders\" alt=\"SlideShow Preloaded Images\" /></div>\n");


  // Begin the magic..
  webPage.concat("
\n<script>\n");

  /*
    Most of what follows should be fairly cross-browser compatible HTML5 standards-compliant code.
    The slight jiggery-pokery needed to support Firefox is nothing compared to the hell of getting
    Internet Explorer to play with anything /at all/ on the early web. I still get flashbacks.

    I intentionally code in these work-arounds because the last thing I want is to give someone a
    reason to ditch Firefox.

    I test on Chrome (which most people use), Edge (on Linux, and occasionally on Windows) and
    Firefox desktop browsers. Chrome, Bromite and Kiwi browsers on Android.

  */


  // We drop variables from C directly into the markup, so JavaScript can use them.
  // When variables mean the same thing, we usually keep the same names.

  webPage.concat("
const thumbView = " + (String)( (thumbView) ? "true" : "false" ) + ";\n");
  webPage.concat("
var doPoP = " + (String)((doPoP) ? "true" : "false" ) + ";\n");
  webPage.concat("
var loopPoP = " + (String)((loopPoP) ? "true" : "false" ) + ";\n");
  webPage.concat("
var stickyPoP = " + (String)((stickyPoP) ? "true" : "false" ) + ";\n");
  webPage.concat("
var streamANDRecord = " + (String)((streamANDRecord) ? "true" : "false" ) + ";\n");
  webPage.concat("
var zmAcelleration = " + (String)zmAcelleration + ";\n");
  webPage.concat("
var seenTWarn = " + (String)((seenTWarn) ? "true" : "false" ) + ";\n");
  webPage.concat("
var seenPWarn = " + (String)((seenPWarn) ? "true" : "false" ) + ";\n");
  webPage.concat("
const upDownRev = " + (String)((upDownRev) ? "true" : "false" ) + ";\n");
  webPage.concat("
const bgColor = \"" + (String)bgColor + "\";\n");
  webPage.concat("
const ImgRange = \"" + imgTitleName + " \" + " + (String)(displayFrom) );
  webPage.concat("
 + \"-\" + " + (String)(displayFrom+imagesPerPage-1) + ";\n");
  webPage.concat("
var loadingIcon = " + (String)( (loadingIcon) ? "true" : "false" ) + ";\n");
  webPage.concat("
const loadingMsg = \"" + (String)(loadingMsg) + "\";\n");
  webPage.concat("
var slideTime = " + (String)slideTime + ";\n");
  webPage.concat("
const imgTallyName = \"" + imgTallyName + "\";\n");
  webPage.concat("
const webMessageTime = " + (String)webMessageTime + ";\n");
  webPage.concat("
const openWithPrefs = " + (String)( (openWithPrefs) ? "true" : "false" ) + ";\n");
  webPage.concat("
const openWithSlides = " + (String)( (openWithSlides) ? "true" : "false" ) + ";\n");
  webPage.concat("
const openWithHelp = " + (String)( (openWithHelp) ? "true" : "false" ) + ";\n");
  webPage.concat("
const autoPlaying = " + (String)( (autoPlaying) ? "true" : "false" ) + ";\n");

  webPage.concat("
const amStreaming = " + (String)( (amStreaming) ? "true" : "false") + ";\n");
  webPage.concat("
var fitMode = " + (String)( (fitMode) ? "true" : "false" ) + ";\n");

  webPage.concat("
var nightTime = " + (String)( (nightTime) ? "true" : "false" ) + ";\n");

  webPage.concat("
var floating = " + (String)( (floating) ? "true" : "false") + ";\n");
  webPage.concat("
var floatX = " + (String)( (floatX != "") ? "\"" + floatX + "\"" : "undefined" ) + ";\n");
  webPage.concat("
var floatY = " + (String)( (floatY != "") ? "\"" + floatY + "\""  : "undefined" ) + ";\n");


/*
  I realise that these days it's actually possible to simply do preferences.yaddayadda, rather than
  first doing document.getElementById("
preferences"), but ffs! That is a world of future mind-
  fuckery-debugging-hell waiting to happen and; for some reason; was implemented to support stupid
  non-standard browser behaviour that careless developers thought might be a way to what? "
simplify"
  their code? No. I won't go there. And if /you/ do, God help you and those who come (to your code)
  after you.

  There are even online "
tutorials" using that method; maybe they fancy they can push some kind of
  standard. Fuck off. You have no clue. Remove this stupid shit from the spec and let apps fail,
  then get fixed and coded properly! Sheesh!

  It's like literally meaning figuratively, literally! (exclamation mark donating "
humour")

  While I'm here, will so-called "
experts" stop putting numbers in quotes! THEY ARE NOT STRINGS!
  If you would care to test on more than your one favourite browser, you would realise that your
  code is total bollocks.

  Google Chrome does not have a 100% share of the market. Yet*.

  * While I'm no fan of Google, Chrome is top-notch and all my tests tell me it's the best browser
    available at this time, for many reasons. Chromium is probably even better, but I no longer use
    it since Google removed the ability to use Sync. ****s!

*/

  webPage.concat(R"
HTML5(
var theImages;
var messageShowing = false;
const pulseTime = 150;
var msgTimer, prefsMsgTimer, thumbHeight, prefsOpen, helpOpen;
const prefsPane = document.getElementById("preferences");
const helpPane = document.getElementById("help");
const prefsMessage = document.getElementById("prefs-message");
const msgTitle = document.getElementById("message");
const streamHolder = document.getElementById("livestream");
var streamOpen = false, rotationAngle = 0, tinyStream = false, recordingStream = false;
var pollCount = 0;
var touchStarted = false, touchToggleAutoPlay = false;
var PoPLoaded = false;
// Fast As F*ck Mode..
var fAF = false, fAFStore = slideTime, fAFTime = 0.2;
const centerTrans = "translate(-50%, -50%)"
var uploadInput;

// SlideShow..
var slideShow, slideShowTimer, slideMsgTimer, slide, slideName, imgSaveLink, sBaseName;
var prevColor, prevBGColor, thisSlide = 0, slideShowOpen = false, slideShowPlaying = false, slidesRev = false;

// Add the image range to the document title..
document.title += " " + ImgRange;

/*
These are measured from Top-Left */

var pointerX, pointerY;

var startT = Date.now();
var buttsHidden = false;
var buttHideTime = 2000; // Minimum time before Previous / Next buttons start to fade out (ms).

/*
Capture pointer position changes into above vars..
We use literal "event", which was a requirement for a time with a particular browser,
but remains a nice clear way to label the thing. */

document.onpointermove = (event) => {
  pointerX = event.x;
  pointerY = event.y;
  if (event.pointerType !== "touch") {
    startT = Date.now();
    if (slideShowOpen && buttsHidden) showSlideButtons();
  }
}

// console.warn("document.location.origin: %o", document.location.origin); //debug

)HTML5");

  // Only load this code if PoP-Up Previews are enabled..
  if (doPoP) {

    webPage.concat("
var fileEXT = \"" + (String)fileEXT + "\";");

    // I like to use HEREDOC wherever possible for HTML.
    // Saves having to escape double-quotes and what-not.
    // You can even put variables in it, a-la printf. For short strings that works well. Not here.

    webPage.concat(R"
HTML5(

// PoP vars..
PoPLoaded = true;
var iLinks, bottomOfThumbs, rightOfFileList, xPOS, yPOS, donePoPMSG = false, poPShowing = false;
const PoPIMG = document.getElementById('PoP');

const getScrollbarWidth = () => window.innerWidth - document.documentElement.clientWidth;

// Groovy pop-up preview.
// All this code is about utilising the available space for a big-as-possible preview.
// It's not perfect on /every/ browser, but mostly works as expected.
function showPoP(popImage, position=true, touch=false, forward=true) {

  if (!doPoP) return; // In case it was switched off after page-load.

  if (position) {

    // Thumbnails are bigger than preview size! Erm ...
    if (thumbView && (thumbHeight > (window.innerHeight / 2) )) {
      if (!donePoPMSG) {
        setMessage("Thumbnails are bigger than preview!");
        donePoPMSG = true;
      }
      return;
    }

    // Get the latest-greatest figures.. (a just-in-case for certain browsers)
    sizeUpdate();
  }

  PoPIMG.src = popImage;

  if (position) {

    PoPIMG.style.transform = "none" // Don't forget to reset!
    var overRideX = false, overRideY = false;

    // Default size..
    PoPIMG.style.height = "calc(50vh - 1em)";
    PoPIMG.style.width = "auto";

    // Default position (for top-left of the pop-up image)..
    xPOS = 0;
    if ( (window.innerWidth - getScrollbarWidth()) > (PoPIMG.clientWidth * 2) ) {
      xPOS = (window.innerWidth / 2) - (getScrollbarWidth() / 2);
    }
    yPOS = (window.innerHeight / 2);


)HTML5");
  /*
    If your rows(s) of thumbnails take up less than half the viewport height, we will place PoP
    directly in the middle of the empty bottom section and keep it fixed there, regardless of your
    pointer's X coordinates. This makes for real-easy-on-the-eyes previews which turn out to also be
    lots of fun. On top of that, we will automatically resize PoP so that it fills /all/ the
    available space. We call this "
Gallery Mode". AI, baby!

    Similarly, in list view, we measure the X coordinate of the right-hand side of the list, and if
    there's enough available space to the right, we fix the PoP there, as-big-as-can-be. I'm always
    thinking.. "
If I were me, what I do?". Then I code it.

    The methodology is thus: Directly set one CSS property (width or height), using all the CSS
    trickery we can muster (stuff like calc(), and so on) then set the /other/ property to "
auto",
    and let the /browser/ work it out. As SOON as this is done (immediately) we can query the DOM
    for information about the /other/ value, and use it right away in our JavaScript calculations.

    This mix-and-match approach works well for MCU web serving; dropping C variables right into the
    markup as JavaScript variables and using them, along with dynamic CSS and JavaScript processing
    of user actions (viewport size, pointer position, etc.) to produce the final calculated result.

    It's like this: JavaScript knows nothing of "
em". So, these layout calculations need to be done
    in CSS which /understands/ "
em", and other layout-specific things, being ITS JOB. The CSS calc()
    function (which wasn't around when I first created my PHP css-init, which does this sort of
    thing and much more) enables us to effortlessly drop JavaScript and CSS into the very same
    layout calculation; all three layers working seamlessly to produce our desired result.

    Sure, CSS and JavaScript; which these days is near idiot-proof; don't always /precisely/ agree
    on what these dynamic values are (in which case, we defer to CSS*), it's close enough for what
    we are doing here, and the end results are exactly what I was looking for. Boom!

    * In other words, when we are inserting a CSS value in JavaScript, we use CSS's "
100vh" over
    JavaScript's window.innerHeight, every time. Where CSS fails us (i.e. scrollbar widths) or it's
    simply not possible (Syat!**), we use JavaScript to calculate the value. So it goes.

    ** As I was taught: To the best of my knowledge, at this time.


                            */

  webPage.concat(R"
HTML5(

    // I'll bet aliens can't do this..
    var narrowPoP = "calc( 100vw - " + getScrollbarWidth() + "px - 1em )";

    if (thumbView) {

      // We have the space. Let's do: Gallery Mode..
      if (bottomOfThumbs < (window.innerHeight / 2) ) {

        // Use /all/ that space..
        PoPIMG.style.height = "calc( 100vh - " + bottomOfThumbs + "px - 1em )";
        PoPIMG.style.width = "auto";

        // Oh Oh! Super-Narrow Viewport..
        if (PoPIMG.clientWidth > window.innerWidth) {
          PoPIMG.style.width = narrowPoP;
          PoPIMG.style.height = "auto";
        }

        // Whatever, PoP top is here..
        yPOS = bottomOfThumbs;

        // Place it exactly in the middle..
        PoPIMG.style.transform = "translateX(-50%)";
        PoPIMG.style.left = "50%";
        overRideX = true;

      } else { // Regular pointer avoidance..

        // *urgh* :repeat:..
        if (PoPIMG.clientWidth > window.innerWidth) {
          PoPIMG.style.width = narrowPoP;
          PoPIMG.style.height = "auto";
        }

        // Prevent bottom view falling off bottom edge on short viewport..
        if (PoPIMG.clientHeight > (window.innerHeight / 2) ) {
          PoPIMG.style.height = "calc(50vh - 1em)";
          PoPIMG.style.width = "auto";
        } // I like this by itself!

        // These 2 lines are the original PoP logic..
        if (pointerX > (window.innerWidth / 2) ) { xPOS = 0; }
        if (pointerY > (window.innerHeight / 2) ) { yPOS = 0; }
      }


    } else {  // List View..

      let availableWidth = window.innerWidth - rightOfFileList - getScrollbarWidth();

      // If we have the space, fix PoP in the empty space on the right of the listing..
      if (availableWidth > parseInt(getComputedStyle(PoPIMG).getPropertyValue("width")) ) {

        // Constrain tall images..
        if (PoPIMG.naturalHeight > PoPIMG.naturalWidth) {

          PoPIMG.style.height = "calc( " + window.innerHeight + "px - 1em )";
          PoPIMG.style.width = "auto";

          let middleOfSpace = rightOfFileList + (availableWidth / 2);

          PoPIMG.style.left = "calc( (" + middleOfSpace + "px  - ( "
           + getComputedStyle(PoPIMG).getPropertyValue("width") + " / 2) ))";

        } else {

          // Wide images..
          PoPIMG.style.width = "calc(" + availableWidth + "px - 2em)";
          PoPIMG.style.height = "auto";
          PoPIMG.style.left = "calc(" + rightOfFileList + "px + 0.5em)";
        }

        // Vertically "center"..
        PoPIMG.style.top = "50%";
        PoPIMG.style.transform = "translateY(-50%)";
        overRideX = true;
        overRideY = true;

      } else {

        if (PoPIMG.clientWidth > window.innerWidth) {
          PoPIMG.style.width = narrowPoP;
          PoPIMG.style.height = "auto";
          PoPIMG.style.left = "50%";
          PoPIMG.style.transform = "translateX(-50%)";
          overRideX = true;
        }

        // Same regular pointer avoidance on the Y-axis..
        if (pointerY > (window.innerHeight / 2) ) { yPOS = 0; }
      }
    }

    // Position PoP..
    if (!overRideX) PoPIMG.style.left = (xPOS == 0) ? "0.5em" : xPOS + "px";
    if (!overRideY) PoPIMG.style.top = (yPOS == 0) ? "0.5em" : yPOS + "px";

  }

  clearHighlights();
  let poPMatch = returnPoPMatch();
  iLinks[poPMatch].classList.add("pop-highlight");

  // Current image link is out of view - scroll it into view..
  let myDimensions = iLinks[poPMatch].getBoundingClientRect();
  if ( ( myDimensions.bottom > window.innerHeight ) || ( myDimensions.top < 0 ) ) {
    iLinks[poPMatch].scrollIntoView({ behavior: "instant", block: "center", inline: "nearest" });
  }


  if (touch) {

    /*
      Simple Sliding animation..
      Not as easy on your browser as a translate, but needs must.
                                  */

    let pL = PoPIMG.style.left;
    if (!forward) { // previous
      PoPIMG.animate( [ { left: "0"}, { left: pL } ], { duration: 100, iterations: 1 } );
    } else { // next
      PoPIMG.animate( [ { left: "100vw"}, { left: pL } ], { duration: 100, iterations: 1 } );
    }
  }

  // Show PoP..
  PoPIMG.style.opacity = "1";
  PoPIMG.style.visibility = "visible";
  poPShowing = true;

}


// Pulse when the end has been reached but you keep going anyway..
// If you feel devious, you can trick it into staying highlighted.
function pulseLink(index) {
  let flashCount = 1;
  let endersGame = setInterval( function() {
    if (flashCount++ === 2) clearInterval(endersGame);
    iLinks[index].classList.remove("pop-highlight");
    let notFlashy = setTimeout( function() {
      iLinks[index].classList.add("pop-highlight");
    }, 125); // Same length of time as the CSS transition.
  }, 225); // No faster than 3 flashes per second, of course (epilepsy).
}

function hidePoP(vanish=false) {
  if (stickyPoP && !vanish) return;
  PoPIMG.style.visibility = "hidden";
  PoPIMG.style.opacity = "0";
  clearHighlights();
  poPShowing = false;
}

// A pragmatic approach.
function clearHighlights() {
  if (iLinks.length !== undefined) {
    for (let i = 0; i < iLinks.length; i++) {
      iLinks[i].classList.remove("pop-highlight");
    }
  }
}

function loadNextPoP(reverse=false, key=false) {

  let poPMatch = returnPoPMatch(-1);
  var nextPoP;

  // There is no matching PoP (HotKey open or deleted last image). Load first PoP, instead..
  if (poPMatch == -1) nextPoP = 0;

  if (poPShowing) {

    if (!reverse) { // Next..

      nextPoP = poPMatch+1;
      if (loopPoP) {
        if (nextPoP == iLinks.length) nextPoP = 0; // OK
      } else {
        if ( nextPoP === theImages.length ) {
          pulseLink(nextPoP-1);
          return;
        }
      }

    } else  { // Previous..

      nextPoP = poPMatch-1;
      if (loopPoP) {
        if (nextPoP == -1) nextPoP = iLinks.length-1; // OK
      } else {
        if ( nextPoP === -1 ) {
          pulseLink(nextPoP+1);
          return;
        }
      }
    }
  }

  if (thumbView) {
    showPoP(theImages[nextPoP].src.split("/").pop(), key);
  } else {
    showPoP(iLinks[nextPoP].href.split("/").pop(), key);
  }

}

// Hit Delete key while PoP is showing to delete the current image..
function deletePoPImage() {
  let poPMatch = returnPoPMatch(false, true);
  if (poPMatch) {
    let goBack = false
    if (returnPoPMatch() == iLinks.length-1) goBack = true;
    deleteImage(poPMatch);
    loadNextPoP(goBack);
  }
}


// Returns a valid file name or index in the current live collection (of links or thumbs)..
function returnPoPMatch(falseValue=false, returnName=false, matchFirst=false) {

  for (let i = 0; i < iLinks.length; i++) {

    var poPMatch = falseValue;

    if (thumbView) {
      if (PoPIMG.src.indexOf(iLinks[i].getElementsByTagName("img")[0].src) !== -1) poPMatch = true;
    } else {
      if (iLinks[i].href == PoPIMG.src) poPMatch = true;
    }

    if (poPMatch !== falseValue || matchFirst) {
      poPMatch = i;
      if (thumbView) {
        if (returnName) poPMatch = theImages[i].src.split("/").pop();
      } else {
        if (returnName) poPMatch = iLinks[i].href.split("/").pop();
      }
      break;
    }
  }
  return poPMatch;
}



)HTML5");
  }
  /* That's a chunk of JavaScript we can omit if we're not making PoP. */

  webPage.concat(R"
HTML5(

function takeSnap() {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        setMessage("SNAP!");
      } else {
        setMessage("FAIL!<br>" + this.responseText);
      }
    }
  };
  AJAX.open("GET""snap", true);
  AJAX.send();
}


function openPrefs() {
  if (prefsOpen) return;
  prefsOpen = true; // Only add the parameter if it isn't there already.
  if (!/prefs/i.test(window.location)) window.history.replaceState("object""", makeGETParams() + "&prefs=true");
  prefsPane.style.opacity = "1";
  prefsPane.style.visibility = "visible";

  if (!seenPWarn) {
    let wMsg = "<span class='warning'>NOTE: <small>";
    wMsg += "All settings take effect <strong>immediately</strong>.</small></span>";
    setTLCAMSettings('spw', '-', true); // Sets a cookie.
    setMessage(wMsg, webMessageTime * 3);
    seenPWarn = true;
  }
}

function closePrefs() {
  if (!prefsOpen) return;
  clearMessage(webMessageTime); // Get updated file count.
  prefsOpen = false;
  window.history.replaceState("object""", makeGETParams("prefs"));
  prefsPane.style.visibility = "hidden";
  prefsPane.style.opacity = "0";
}



function openHelp() {
  if (helpOpen) return;

  if (!/help/i.test(window.location)) window.history.replaceState("object""", makeGETParams() + "&help=true");
  helpPane.style.opacity = "1";
  helpPane.style.visibility = "visible";
  helpOpen = true;
}

function closeHelp() {
  if (!helpOpen) return;
  helpOpen = false;
  window.history.replaceState("object""", makeGETParams("help"));
  helpPane.style.visibility = "hidden";
  helpPane.style.opacity = "0";
}



function setSnapInterval(thisControl, newInterval, timeFrame) {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        // "OK" Messages always begin with the String "OK" butted up against the start. We remove these two characters.
        setMessage(this.responseText.substring(2));
        // Radio-button like behaviour..
        // (the <label> is the parentNode, so we need to go up and up again)
        let snapSelectors = thisControl.parentNode.parentNode.getElementsByTagName("select");
        for ( let i = 0 ; i < snapSelectors.length ; i++ ) {
          if (snapSelectors[i] !== thisControl) {
            snapSelectors[i].getElementsByTagName("option")[0].selected = "selected";
          }
        }
      } else {
        setMessage("Failed!");
      }
    }
  };
  AJAX.open("GET""interval?" + timeFrame + "s=" + newInterval, true);
  AJAX.send();
}


// Check the current snap interval time..
function getIntervalTime() {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        setMessage(this.responseText.substring(2));
      } else {
        setMessage("FAIL!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET""getint", true);
  AJAX.send();
}


// Alter TL-CAM settings.
function setTLCAMSettings(sendParam, newValue, silent=false) {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {

      // * For small, simple strings of known case, indexOf() is fastest..
      if (this.responseText.indexOf("OK") !== -1) {

        // We will reload the page and immediately re-open the prefs panel (used for style/theme changes).

        // ** Where case is unknown, test() is best..
        if (/reload/i.test(this.responseText)) {
          setMessage(loadingMsg);
          location.reload();

        // Some setting requires a reboot.
        } else if (/reboot/i.test(this.responseText)) {
          setMessage(this.responseText.substring(2), webMessageTime * 2, "settings");
          rebootAndRefresh();

        // All Good. Post response..
        } else {
          if (!silent && this.responseText.substring(2) !== "") setMessage(this.responseText.substring(2));
        }

      } else {
        setMessage("Settings FAIL!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET""settings?" + sendParam + "=" + newValue, true);
  AJAX.send();
}

// Wrapper for Boolean settings..
function boolSetting(myCheckBox, sendParam) {
  setTLCAMSettings(sendParam, myCheckBox.checked ? "true" : "false");
}

// Wrapper for Auto DarkMode settings..
function autoDMSetting(myCheckBox, sendParam) {
  boolSetting(myCheckBox, sendParam);
  let darkCheck = document.getElementById("darkmode");
  darkCheck.disabled = (myCheckBox.checked) ? true : false;
  setIntermediateCheckBox(darkCheck);
}

// Intermediate looks nicer than plain disabled..
function setIntermediateCheckBox(element) {
  element.indeterminate = element.disabled;
}

// Wrapper for slider settings.. (would be trivial to make this a generic function, if required)
function setAcceleration(mySlider, rangeCode) {
  zmAcelleration = mySlider.value;
  setTLCAMSettings(rangeCode, zmAcelleration);
  mySlider.title = mySlider.value;
}

// Set the info title to the current value of the slider.
function updateSliderLiveTitle(slider) {
  setMessage(slider.value);
}

// NVS-related functions..
function doNVSFunctions(type) {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      setMessage(this.responseText.substring(2), webMessageTime * 1.5);
      if (/reboot/i.test(this.responseText)) rebootAndRefresh();
    }
  };
  AJAX.open("GET", type, true);
  AJAX.send();
}
)HTML5");

// Poll for reboot complete..
// Poll every second until we receive a response.
// The first response we get back will probably be a CONNECTION REFUSED error, meaning the device
// has rebooted but the server isn't ready yet (waiting to get internet time, probably).
// In a moment it will be, and our next request should return "OK".

  webPage.concat(R"
HTML5(
function rebootAndRefresh() {

  let AJAX = new XMLHttpRequest();

  // Something is wrong.
  if (pollCount > 22) {
    setMessage("No response from TL-CAM. Giving up now.. \nIf you have no physical access, wait a minute and manually refresh this page.\n It may be an NTP server issue.");
    pollCount = 0;
    return;
  }

  // It doesn't look like the server is coming back up, but we'll keep trying.
  if (pollCount == 11) setMessage("Still no response.\nA manual reboot may be required.");

  // Keep the user in the loop. On a local connexion, we may be back up by now..
  if (pollCount == 4) setMessage("Querying TL-CAM for reboot status..");

  // "Something is happening"..  (Shpongle reference)
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      setTimeout ( function() {
        setMessage("Reboot complete\nRefreshing the page...");
        pollCount = 0;
        location.reload();
      }, 1000 );
    }
  };

  AJAX.open("GET""rebooted", true);
  AJAX.timeout = 1000;
  AJAX.ontimeout = () => { pollCount++; rebootAndRefresh(); };
  AJAX.onerror = () => { setTimeout ( function() { pollCount++; rebootAndRefresh(); }, 972 ); }; // DEFINITELY NOT 973!
  AJAX.send();
}



// Eject / Insert SD Card..
function doSDFunctions(action) {
  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        setMessage(this.responseText.substring(2));
      } else {
        setMessage("FAIL!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET", action, true);
  AJAX.send();
}



// Print out SD Card space information..
function doGetInfo() {

  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        setMessage(this.responseText.substring(2), webMessageTime * 3);
      } else {
        setMessage("FAIL!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET""info", true);
  AJAX.send();
}

// Print out memory information..
function doGetMem() {

  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {
        setMessage(this.responseText.substring(2), webMessageTime * 3);
      } else {
        setMessage("FAIL!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET""memory", true);
  AJAX.send();
}


// Delete an image, by thumbnail/link delete button or "delete" key in slideshow..
function deleteImage(imageFile) {

  let AJAX = new XMLHttpRequest();
  AJAX.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      if (this.responseText.indexOf("OK") !== -1) {

        let myMSG = this.responseText.substring(2);         // Format for slideshow..
        if (slideShowOpen) myMSG = myMSG.replace("deleted""<br>\n<strong class='warning'>deleted</strong>");

        document.getElementById(imageFile).remove();
        if (!thumbView && doPoP) document.getElementById("pl-" + imageFile).remove();

        if (slideShowOpen) {
          pulseRED();
          resetSlideTimer();
          showSlide();
          setMessage(myMSG);
        } else {
          setMessage(myMSG);
          if (doPoP && !stickyPoP) hidePoP(true); // If it was the last image in the set, PoP would "linger" otherwise.
        }

      } else {
        setMessage("Delete Failed!\n" + this.responseText);
      }
    }
  };
  AJAX.open("GET""delete?file=" + imageFile, true);
  AJAX.send();
}

)HTML5");

/*
  SlideShow.

  This is entirely JavaScript Magic (JM) and uses zero resources on your ESP32 module (assuming you
  have image caching enabled). We simply loop through the DOM's list of images (thumbnails) on the
  page (which remember are the full-sized images scaled down by your browser) and display them
  viewport or screen-sized in a nice dark overlay.

  Click the title to start the magic. Ctrl-Click to go full-screen.

  This also works in List View, when PoP-Up Previews are enabled (we use the hidden preloaded images
  instead).

  There are left and right buttons to move you to the previous / next image in the series. When you
  reach either end, it loops around to the other end, and pulses the background to let you know.

  Left/Right, as well as Down/Up arrow keys also perform these functions; makes for swift previews.
  You can switch around what Up/Down do, in the prefs at the top.

  While you are zipping through your snaps you can click on the image or hit "
s" to save the image
  to your device. Hit "
Delete" to delete an image. Yes, even when the slideshow is /playing/. The
  deleted image will display for a moment, then the next image will load.

  Spacebar to toggle auto-play, in whatever direction you were previously travelling. This is a
  great way to review time-lapse images. NOTE: The slideshow always starts up in regular forward
  motion. To switch the direction of the slideshow, simply move in that direction; i.e. click [<][>]
  (previous/next image) or hit the left/down//up/right arrow.

  Hit SPACEBAR from the main page to go directly to an auto-play slideshow. Add Ctrl to go full-
  screen. SHIFT-Click any thumb/link to open slideshow mode /at/ that image. Add Ctrl key for full-
  screen, as usual. No one uses Shift-Click to open an image in a new window, so we are using it.

  If you want to change the slideshow time, hit "
," (comma) to bring up the prefs panel.
  Changes take effect immediately (or rather, at the end of your current slide).

  Click anywhere outside the image (or hit <enter>) to exit the slideshow.

*/


  webPage.concat(R"
HTML5(

// SlideShow..

// Play/Stop SlideShow..
function toggleAutoPlay(notify=true) {

  if (!slideShowPlaying) {

    slideShowPlaying = true;
    setMessage("SlideShow Playing");
    autoPlay(true, true);
    if (notify) pulseGREEN();

  } else {

    slideShowPlaying = false;
    clearTimeout(slideShowTimer);
    setMessage("SlideShow Stopped");
    if (notify) pulseYELLOW();
  }
}


// Auto-Advance (aka. Play)
function autoPlay(next = false, delayStart = false) {

  if (!slideShowPlaying) return;
  if (!/play/i.test(window.location)) window.history.replaceState("object""", makeGETParams() + "&play=true");

  if (!delayStart) { // delay before placing 1st image
    if (next) {
      if (!slidesRev) {
        nextSlide();
      } else {
        previousSlide();
      }
    } else {
      showSlide();
    }
  } else clearTimeout(slideShowTimer);

  slideShowTimer = setTimeout(autoPlay, slideTime * 1000, true);
}

// Toggle Fast As F**k Mode. Wheeeeee!!!
function toggleFastAFMode(report = false) {

  fAF = fAF ? false : true;
  if (fAF && slideShowOpen && !slideShowPlaying) toggleAutoPlay();

  if (fAF) {
    if (report) setMessage("fAF Mode Enabled");
    fAFStore = slideTime;
    slideTime = fAFTime;
  } else {
    if (report) setMessage("fAF Mode Disabled");
    slideTime = fAFStore;
  }
}

// All user actions in slideshow reset the slide timer back to zero.
function resetSlideTimer() {
  clearTimeout(slideShowTimer);
  autoPlay(false);
}

// Passing an index seems easier, BUT what if an image gets deleted? So ...
function openSlideShowAt(thisIMG) {
  if (event.shiftKey == true) {
    event.preventDefault();
    for ( let i = 0 ; i < theImages.length ; i++ ) {
      if (theImages[i].src.split("/").pop() == thisIMG) {
        openSlideShow(i);
        return;
      }
    }
  }
}

// Bring up the SlideShow..
function openSlideShow(currentSlide = 0) {

  slideShowOpen = true;
  slidesRev = false;

  if (fAF) {
    slideTime = fAFStore;
    fAF = false;
  }

  if (event.ctrlKey == true) {

    // Remove potential vertical scrollbar when thumbs exceed viewport height..
    if (thumbView) {
      document.getElementById("thumbnails").style.display = "none";
    } else {
       document.getElementById("listing").style.display = "none";
    }
    slide.style.border = "0";
    slide.style.height = "100vh"//TODO super-wide?
    document.documentElement.requestFullscreen();
  }

  slideShow.style.opacity = "1";
  slideShow.style.visibility = "visible";
  thisSlide = currentSlide;
  if (!/slideshow/i.test(window.location)) window.history.replaceState("object""", makeGETParams() + "&slideshow=true");
  showSlideButtons();
  showSlide();
}


// Exit the SlideShow. (<Enter> / swipe up / click/tap outside slide)
function closeSlideShow(touch=false) {

  clearTimeout(slideShowTimer);

  if (slideShowPlaying) {
    window.history.replaceState("object""", makeGETParams("play"));
    slideShowPlaying = false;
  }

  slideShowOpen = false; // Two changes to the history in quick succession is fine. Three, no.
  window.history.replaceState("object""", makeGETParams("slideshow"));
  clearMessage();

  // User may have exited full-screen manually..
  if (thumbView) {
    document.getElementById("thumbnails").style.display = "block";
  } else {
     document.getElementById("listing").style.display = "block";
  }

  if (document.fullscreenElement) {
    slide.style.border = "0.5em solid var(--bg-color)";
    slide.style.height = "calc(100vh - 1em)";
    document.exitFullscreen();
  }

  let timeOut = 0;
  if (touch) {
    timeOut = 200;
    slideShow.style.transition = "opacity 200ms ease-in 0s";
    slide.animate(
      [ {transform: "translate(-50%, -50%)" }, { transform: "translate(-50%, -100%)" } ],
      { duration: 200, iterations: 1}
    );
  }

  slideShow.style.opacity = "0";
  let slideExit = setTimeout( function() {
    slideShow.style.visibility = "hidden";
    slideShow.style.transition = "none";
    slideShow.style.transform = "none";
    if (fAF) toggleFastAFMode();
  }, timeOut);
}

// Wrappers for user-activated forward/backward slideshow motion..
function userPreviousSlide(touch=false) {
  slidesRev = true;
  resetSlideTimer();
  previousSlide(touch);
}
function userNextSlide(touch=false) {
  slidesRev = false;
  resetSlideTimer();
  nextSlide(touch);
}

function previousSlide(touch=false) {
  thisSlide--;
  if (thisSlide < 0) {
    thisSlide = theImages.length-1;
    setMessage("wrap");
    if (!fAF) pulseLIGHTER();
  }
  showSlide();
  // We do a simple animation for swipes, makes for better feedback. This is part 2 of the fun.
  if (touch) {
    slide.animate(
      [ {transform: "translate(-100%, -50%)"}, { transform: centerTrans } ],
      { duration: 200, iterations: 1}
    );
  }
}

function nextSlide(touch=false) {
  thisSlide++;
  if (thisSlide == theImages.length) {
    thisSlide = 0;
    setMessage("wrap");
    if (!fAF) pulseLIGHTER();
  }
  showSlide();
  if (touch) {
    slide.animate(
      [ {transform: "translate(100%, -50%)"}, { transform: centerTrans } ],
      { duration: 200, iterations: 1}
    );
  }
}

// Show a slide..
function showSlide() {
  slide.style.top = "50%";
  if (!theImages[thisSlide]) thisSlide--; // User probably deleted highest number image in collection
  if (theImages.length !== 0) {
    if (!theImages[thisSlide].src) thisSlide = 0;
    slide.src = theImages[thisSlide].src; // After deletion, this nicely becomes the /next/ image (as it's a *live* collection)
    sBaseName = theImages[thisSlide].src.split("/").pop();
    imgSaveLink.href = sBaseName; // download link
    imgSaveLink.download = sBaseName; // download file name
    slideName.innerHTML = sBaseName.split(".").slice(0, -1).join("."); // Display name at viewport top-left (minus extension)
  } else {
    closeSlideShow();
  }

  if ( !buttsHidden && (Date.now() - startT) > buttHideTime) {
    hideSlideButtons();
  }
}

// buttons show faster than they hide..
function showSlideButtons() {
  let prev = document.getElementById("previousSlide")
  let next = document.getElementById("nextSlide")
  prev.style.transition = "all 150ms ease-out 0s";
  next.style.transition = "all 150ms ease-out 0s";
  prev.style.opacity = "1";
  next.style.opacity = "1";
  buttsHidden = false;
}

function hideSlideButtons() {
  let prev = document.getElementById("previousSlide")
  let next = document.getElementById("nextSlide")
  prev.style.transition = "all 667ms ease-in 0s";
  next.style.transition = "all 667ms ease-in 0s";
  prev.style.opacity = "0";
  next.style.opacity = "0";
  buttsHidden = true;
}


// SlideShow Wrap Indicator Pulse..
function pulseLIGHTER() {
  doPulse("#a5ff74;""rgba(40,94,45,.80)"); // #285e2d;
}
// SlideShow Delete Pulse..
function pulseRED() {
  doPulse("#ff160e;""rgba(255,22,14,.90)"); // Same as text
}
// Pulse on play/stop slideshow..
// Play
function pulseGREEN() {
  doPulse("#70ff33;""rgba(112,255,51,.90)"); // ditto
}
// Stop
function pulseYELLOW() {
  doPulse("#ffcc00;""rgba(255,204,0,.90)"); // If I use a double quote here to ditto the compiler craps-out. WTF!
}
// Pulse the slideshow background..
function doPulse(textColor, bgColor) {
  slideName.style.color = textColor;
  slideShow.style.background = bgColor;
  clearPulse();
}
function clearPulse() {
  setTimeout( function() {
    // Previous colors gathered on load
    slideName.style.color = prevColor;
    slideShow.style.background = prevBGColor;
  }, pulseTime);
}


// Yes I realise that 1em on X and Y will amount to a *slightly* different frame aspect ratio. I can handle it.


// Live stream preview.
// Everything else has a higher priority than the live stream. Click to switch between fit & full mode.
// You can do other stuff while the stream is open; it will simply drop frames if required.
// If the stream is smaller than the viewport, you can drag it out of the way and continue working.
function openLiveStream() {

  // streamHolder.style.background = "red"; // debug

  // Enable pointer drag to quickly reposition the stream ..
  let dragging = false, PointerDown = false, dragStarted = false;
  let pointerStartX = 0, pointerStartY = 0;

  // Default styles we can use to reset the stream when returning to "fit" view..
  const holderFitStyles = `
background-color: rgba(9,21,10,.66);
position : fixed;
height: 100vh;
width: 100vw;
top: 0;
right: 0;
left: 0;
bottom: 0;
`;

  if (!/live/i.test(window.location)) window.history.replaceState("object""", makeGETParams() + "&live=true");

  // We'll create a container div for the whole lot, in case we need to do funky stuff with it later.
  const frame = document.createElement("div");

  // We use an iFrame so we can reliably /close/ the stream connexion.
  const iframe = document.createElement("iframe");
  let metaTags = document.createElement("meta");
  metaTags.name = "viewport";
  metaTags.content = "initial-scale=1";

  frame.style.zIndex = "800";
  frame.id = "frame";
  frame.style.position = "fixed";
  frame.style.left = "50%";
  frame.style.top = "50%";
  frame.style.transform = centerTrans;
  // frame.style.transform = "translate(-50vw, -50vh)"; // X fails! (nice Y tho) darn.

  iframe.id = "iframe";
  iframe.style.width = "100%";
  iframe.style.height = "100%";
  iframe.style.border = "0.5em solid " + bgColor;
  iframe.style.borderRadius = "1em";
  // iframe.style["border-radius"] // Not well supported, yet.

  // Insert the iFrame and its meta-tags into our document..
  streamHolder.appendChild(frame);
  frame.appendChild(iframe);
  iframe.contentDocument.getElementsByTagName("head")[0].appendChild(metaTags);

  // Create an image tag inside the iFrame. This is the way.
  const stream = document.createElement("img");

  var frameGetFitWidth = () => "calc( ( 100vh * " + (stream.naturalWidth / stream.naturalHeight) + " ) - 1em)";
  const frameFillHeight = "calc( 100vh - 1em )";
)HTML5");

  // Click and drag the full-sized stream to move it in any direction..
  // If you start in the centre, you should be able to drag to every
  // point in the image, even with a 5MP stream.
  // Unless you turn the acceleration right down.

  /*
     Load the stream into our image tag..
                                           */

  // Small delay for Firefox..
  // (so iFrame loading is complete before we attempt to drop stuff /into/ it)
  // This might be a good place to use Promises!
  webPage.concat(R"
HTML5(

  var mainLT = setTimeout (function() {

    iframe.contentDocument.getElementsByTagName("body")[0].appendChild(stream);
    stream.title = "click to toggle auto-fit";
    stream.style.touchAction = "none"// We will handle this. Not your Android.
    stream.id = "stream";
    stream.src = "/mjpeg";

    // In case the stream isn't "ready" yet. Try again. It should now work.
    let reTries = 0;
    stream.onerror = function() {
      console.warn("Stream Failure. Retrying..");
      if (reTries > 3) {
        closeLiveStream();
        setMessage("Failure with live stream.\n<small>(perhaps it is already running)</small>");
        return;
      }
      stream.src = null;
      stream.src = "/mjpeg";
      reTries++;
    }

    // Poll for the stream dimensions before continuing..
    var poll = setInterval(function () {

      if (stream.naturalWidth) {

        clearInterval(poll);

        // These styles don't change..
        stream.style.position = "absolute";
        stream.style.borderRadius = "0.25em";
        stream.style.border = "0";

        // These styles do change..
        frame.style.height = frameFillHeight;
        frame.style.width = frameGetFitWidth();

        // Default view (fit)..
        stream.style.width = "100%"// "calc( 100% - 0.5em )";
        stream.style.left = "50%";
        stream.style.top = "50%";
        // stream.style.top = "calc (50% - 0.5em)";
        stream.style.transform = centerTrans;

        /*
        Click the stream to toggle between "auto-fit" and "fill viewport"..
                                                                                */

        var mX, mY, fitFWidth, fitFHeight;

        stream.onclick = function (event) { // fires /after/ pointerup event.

          // Prevent end-of-drag from switching us back to auto-fit view..
          if (dragging) {
            dragging = false;
            return;
          }

          fitFWidth = frame.clientWidth;
          fitFHeight = frame.clientHeight;

          // For zooming..
          mX = event.clientX;
          mY = event.clientY;

          // Toggle setting..
          fitMode ?  Full() : Fit();

        };

        // Initial setting..
        fitMode ? Fit() : Full();

        // Show the stream..
        streamHolder.style.opacity = "1";
        streamHolder.style.visibility = "visible";
        streamOpen = true;


        /*
           Auto-Fit.
                       */


        function Fit() {

          frame.style.left = "50%";
          frame.style.top = "50%";

          frame.style.height = frameFillHeight;
          frame.style.width = frameGetFitWidth();

          stream.style.width = "100%";
          stream.style.height = "100%";
          stream.style.left = "50%";
          stream.style.top = "50%";
          stream.style.transform = centerTrans;

          streamHolder.style.height = frameFillHeight;
          streamHolder.style.width = frameGetFitWidth();
          streamHolder.style.cssText +=";"+ holderFitStyles; // Actually *updates* current styles.

          fitMode = true;
          floating = false;

          // Set address bar..
          window.history.replaceState("object""", makeGETParams("fit") + "&fit=true");

        }

        /*
          Full-Size (1:1).
                                */

        function Full() {

          // Let the frame FILL the viewport..
          frame.style.height = frameFillHeight;
          frame.style.width = "calc( 100vw - 1em )";

          stream.style.width = "auto"// Now we have the proper image dimensions to work with.
          stream.style.top = "auto";
          stream.style.left = "auto";
          stream.style.transform = "none";
          stream.style.height = "auto";

          // Stream smaller than viewport?
          tinyStream = false;

          // At least one of the dimensions is smaller than the viewport..
          if (stream.width < window.innerWidth || stream.height < window.innerHeight) {

            stream.style.left = "50%";
            stream.style.top = "50%";
            stream.style.transform = centerTrans;

            // viewport is wider..
            if (stream.width < window.innerWidth) frame.style.width = stream.width + "px";

            // viewport is taller..
            if (stream.height < window.innerHeight) frame.style.height = stream.height + "px";

            // Stream is smaller than viewport in both width and height. Shrink the frame..
            if (stream.width < window.innerWidth && stream.height < window.innerHeight) tinyStream = true;

          } else {

            // Zoom-in to actual size. This will put wherever you clicked in the centre of the display.
            var posX = ( ( stream.width / ( fitFWidth / mX ) ) - ( window.innerWidth / 2 ) );
            var posY = ( ( stream.height / ( fitFHeight / mY ) ) - ( window.innerHeight / 2 ) );

            // Do the scroll..
            let iFBody = iframe.contentDocument.getElementsByTagName("body");
            iFBody[0].scrollLeft = posX;
            iFBody[0].scrollTop = posY;
          }

          // A position has been stored for the floating tinyStream.
          // Remove the frame holder overlay and apply the stored position..
          if (tinyStream && floatX !== undefined) {
            frame.style.left = floatX;
            frame.style.top = floatY;
            hideFrameHolder();
            floating = true;
          }

          if (floating) {
            window.history.replaceState("object""", makeGETParams("floatingfloatXfloatY")
             + "&floating=true" + "&floatX=" + frame.style.left + "&floatY=" + frame.style.top);
          }

          fitMode = false;
          window.history.replaceState("object""", makeGETParams("fit") + "&fit=false");

        }

      } // Polling Success

    }, 10); // End Polling

)HTML5");

    /*
       If the current stream size is /bigger/ than your viewport, you can use your mouse to drag
       the stream around, just like you can with a touchscreen device.

       Also comes with acceleration for fandabizodie-fast detail locating. Try it.

       If the stream is /smaller/ than your viewport, you can pick up the live stream and drag it
       around, pop it somewhere out of the way while you continue to work with TL-CAM's interface.
       Works with both mouse and touch, even when the stream is rotated. Enjoy.
    */


  webPage.concat(R"
HTML5(

    // Pointer DOWN..
    stream.addEventListener("pointerdown", function() {

      if (fitMode) return true;
        /*
      switch(event.pointerType) {
          case "mouse":
              break;
          case "pen":
              break;
          case "touch":
              break;
          default:
      }*/

      event.preventDefault(); // So Firefox doesn't attempt to drag-and-drop the image.
      pointerStartX = event.clientX;
      pointerStartY = event.clientY;
      PointerDown = true;
      dragStarted = false;
    }, true);


    // Pointer UP..
    stream.addEventListener("pointerup", function() { endDrag(); }, true );

    // Pointer left the stream area during drag operation. Stop now.
    stream.addEventListener("pointerout", function() { if (PointerDown) endDrag(); }, true);

    // Pointer MOVE..
    stream.addEventListener("pointermove", function() {

      if (!PointerDown) return;

      // Prevent any browser trying to drag-and-drop the image..
      event.preventDefault();

      /*
          BEGIN Drag..
                            */


      if (!dragStarted) {

        // If the pointer moved seven pixels, we consider this a drag and not a click..
        dragging = false;
        if ( (event.clientX > (pointerStartX + 7) || event.clientX < (pointerStartX - 7) ) ||
          (event.clientY > (pointerStartY + 7) || event.clientY < (pointerStartY - 7) ) ) {

          dragStarted = true;
          dragging = true;

          // Move the container out of the way (will produce a nice transition effect)
          if (tinyStream) hideFrameHolder();

        }

        if (!dragging) return;
      }
)HTML5");

      // Drag the small stream to another location in the viewport..
      // All this relies on the fact that modern web browsers are, relatively speaking, FAST AS F**K!

      // This took a minute! My once-dazzling math skills are now like that theme park you see in
      // post-apocalyptic movies. You kick a few things and scrape the rust away then Boom!
  webPage.concat(R"
HTML5(

      if (tinyStream) {

        rotationAngle = parseInt(frame.dataset.rotation || "0");
)HTML5");

        // With rotation == 0, pointer X/Y can be used directly to calculate the new position.
        // When you rotate a thing in CSS, this is no longer the case. It's like using a mouse
        // sideways, or upside down, or that other angle. In other words.. IT'S MATH TIME!
        // Feel free to lift this lovely code. Apache license. ;o)

  webPage.concat(R"
HTML5(

        if (rotationAngle != 0) {

          // Calculate the pointer movement relative to the initial position..
          let deltaX = event.clientX - pointerStartX;
          let deltaY = event.clientY - pointerStartY;

          // Convert degrees to radians..
          let angleAsRads = (-rotationAngle * Math.PI) / 180; // - cuz X/Y

          // Calculate the rotated pointer movement..
          let rotatedPointerX = deltaX * Math.cos(angleAsRads) + deltaY * Math.sin(angleAsRads);
          let rotatedPointerY = -deltaX * Math.sin(angleAsRads) + deltaY * Math.cos(angleAsRads); //TODO Combine.

          // Move the stream to the new position..
          frame.style.left = "calc( " + getComputedStyle(frame).getPropertyValue("left") + " + " + rotatedPointerX + "px )";
          frame.style.top = "calc( " + getComputedStyle(frame).getPropertyValue("top") + " + " + rotatedPointerY + "px )";

        } else {

          // Non-rotated image. Regular manipulations apply..
          frame.style.left = "calc( " +getComputedStyle(frame).getPropertyValue("left")+" - "+(pointerStartX - event.clientX)+ "px )";
          frame.style.top = "calc( " +getComputedStyle(frame).getPropertyValue("top")+" - "+(pointerStartY - event.clientY)+ "px )";
        }

        floating = true;

      } else {

        // Stream is bigger than viewport..

        window.history.replaceState("object""", makeGETParams("floatingfloatXfloatY"));

        // Calculate new scroll position from accelerated pointer movement.
        let nowX = (pointerStartX - event.clientX) / ( 200 / ( zmAcelleration != 0 ? zmAcelleration : 1 ) );
        let nowY = (pointerStartY - event.clientY) / (  100 / ( zmAcelleration != 0 ? zmAcelleration : 1 ) );
        // While it might seem sensible to base the divisor on the motion, we need magic numbers to produce
        // the acceleration! The farther you travel, the faster it gets.

        // Scroll the iFrame body with the drag..
        let iFBody = iframe.contentDocument.getElementsByTagName("body");
        iFBody[0].scrollLeft += nowX;
        iFBody[0].scrollTop += nowY;
      }
    }, true ); // END Pointer Move


    // Stop scrolling the image..
    function endDrag() {
      if (tinyStream && PointerDown) {
        // Record position of floating stream..
        floatX = getComputedStyle(frame).getPropertyValue("left");
        floatY = getComputedStyle(frame).getPropertyValue("top");
        // Add to current address bar..
        window.history.replaceState("object""", makeGETParams("floatingfloatXfloatY")
         + "&floating=true" + "&floatX=" + floatX + "&floatY=" + floatY);
        if (!seenTWarn) {
          let wMsg = "<span class='warning small'>NOTE: <small>";
          wMsg += "Some HotKeys will not work until you tap/click outside the stream</small></span>";
          setTLCAMSettings('stw', '-', true); // Sets a cookie.
          setMessage(wMsg, webMessageTime * 3, "endDrag");
          seenTWarn = true;
        }
      }
      PointerDown = false;
    }

    // We do this mainly to provide a transition effect which sucks into the stream, as opposed to 0,0.
    function hideFrameHolder() {

      streamHolder.style.left = frame.style.left;
      streamHolder.style.top = frame.style.top;

      // Make it effectively invisible..
      streamHolder.style.height = "0";
      streamHolder.style.width = "0";
      // This also means you can drag the stream off the edge of the viewport without invoking scrollbars.
    }


    // We need a second listener inside the iFrame.
    // Curiously, the main listener works fine when the stream /first/ opens. I might look into this.

    // The spec says that events from inside an iFrame will not bubble up to the parent document,
    // which makes sense, at least if the iFrame src is outside your domain. Hmm. Och well, a wee
    // bit duplication. Not them all, but the main ones.

    // This is for if the user hasn't clicked outside the tinyStream yet, as well as for fill mode.

    iframe.contentDocument.body.addEventListener("keydown", (event) => {
// console.warn("iFrame keydown -------------->event: %o\n%O", event, event); //debug

      switch (event.keyCode) {
        case 190:            //   .(dot/point/full stop)  Capture an image
          takeSnap();
          break;
        case 82:            //    R Rotate live view
          rotateStream();
          break;
        case 76:            //    L Close Live Stream
          closeLiveStream();
          break;
        case 192:            //   ' toggle recording
          toggleRecording();
          break;
        case 188:           //    , Prefs
          prefsOpen ? closePrefs() : openPrefs();
          break;
        case 32:           //     SPACEBAR
          if (slideShowOpen) {
            toggleAutoPlay();
          } else {
            openSlideShow();
            slideShowPlaying = true;
            autoPlay(false);
          }
          break;
        case 13:            // Enter (exit slideshow mode)
          event.preventDefault();
          closeSlideShow();
          break;
      }
    }, true);

  }, 100);

}
)HTML5");

/*
  The "
stream" is a simple "multipart/x-mixed-replace" type document; basically an HTTP header we
  send the browser, telling it to keep the connexion open for more data, and then we just keep
  sending jpeg data until the client connexion closes. Chunkie!
 */


  webPage.concat(R"
HTML5(

function closeLiveStream() {
  document.getElementById("frame").remove(); // Closes client connexion.
  window.history.replaceState("object""", makeGETParams("live"));
  streamHolder.style.opacity = "0";
  streamHolder.style.visibility = "hidden";
  streamOpen = false;
  // Switch recording indicator to main window..
  if (recordingStream) startRecordingNotify();
}

// Rotate the Live Stream..
function rotateStream() {

  let frame = document.getElementById("frame");
  let iframe = document.getElementById("iframe");
  let stream = iframe.contentDocument.getElementById("stream");

  // Stream size has changed - store the new dimensions..
  if (fitMode && frame.dataset.natural != stream.naturalWidth) {
    // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
    frame.dataset.natural = stream.naturalWidth;
    frame.dataset.width = frame.style.width;
    frame.dataset.height = frame.style.height;
  }

  let currentRotation = parseInt(frame.dataset.rotation || "0");
  let newRotation = (currentRotation + 90) % 360;

  let fitWidth = "calc( ( 100vh * " + ( stream.naturalWidth / stream.naturalHeight ) + " ) - 0.25em )";
  let fitHeight = "calc( 100vh - 0.5em )";
  let maxWidth = "calc(100vw - 1em)"
  let maxHeight = "calc(100vh - 1em)"

  if ("width" in frame.dataset) fitWidth = frame.dataset.width;
  if ("height" in frame.dataset) fitHeight = frame.dataset.height;

  // Fit..
  if (fitMode) {

    if (newRotation == 90 || newRotation == 270) {
      // In fit mode, we expand the frame to fill the viewport (AMAP), so we switch dimensions..
      frame.style.width = "calc( 100vh - 0.25em )";
      frame.style.height = "calc( ( 100vh * " + ( stream.naturalHeight / stream.naturalWidth ) + " ) - 0.25em )";
      // This will handle both tall and wide, as well as regular images.
    } else {
      frame.style.width = fitWidth;
      frame.style.height = fitHeight;
    }

  // Fill..
  } else if (!tinyStream) {
    if (newRotation == 90 || newRotation == 270) {
      frame.style.width = maxHeight;
      frame.style.height = maxWidth;
    } else {
      frame.style.width = maxWidth;
      frame.style.height = maxHeight;
    }
  }

  // Do the rotation (pure CSS, meaning we'll need to Math the pointer movements for the tinyStream)..
  frame.style.transform = centerTrans + " rotate(" + newRotation + "deg)";
)HTML5");
  // Chrome's fill mode rotation is off-centre. Hmm.

  // We store the rotation data in the frame's dataset.
  // This is lost when you close the frame.
  // I guess we could store the whole lot somewhere else,
  // but this is nice and clean.

  webPage.concat(R"
HTML5(
  frame.dataset.rotation = newRotation.toString();
}


// Toggle stream recording..
function toggleRecording() {
  let AJAX = new XMLHttpRequest();
    AJAX.onreadystatechange = function() {
      if (this.readyState == 4 && this.status == 200) {
        setMessage(this.responseText.substring(2));
        if (/start/i.test(this.responseText)) {
          recordingStream = true;
          startRecordingNotify();
        } else {
          recordingStream = false;
          stopRecordingNotify();
        }
      }
    };
  AJAX.open("GET""switch", true);
  AJAX.send();
}


// Stream Recording Indicator
// (a pulsing red dot in lower-right corner of stream/window)
function startRecordingNotify() {

  stopRecordingNotify();
  var dotParent;

  let recordButt = document.getElementById("stream-record");
  recordButt.innerHTML = "&#x23F9;"; // "⏹"

  if (streamOpen) {
    dotParent = document.getElementById("frame");
  } else {
    dotParent = document.getElementById("dot-holder");
  }
  const dot = document.createElement("div");
  dot.id = "dot";
  dotParent.appendChild(dot);
}


function stopRecordingNotify() {
  let dot = document.getElementById("dot");
  if (dot !== null) dot.remove();
  let recordButt = document.getElementById("stream-record");
  recordButt.innerHTML = "&#x25CF;"; //  "●"
}


// 's' or click image in slideshow..
function saveImage() {
  if (slideShowOpen && !prefsOpen) {
    resetSlideTimer();
    document.getElementById("slide").click();
  }
}


// Upload a file to the SD Card..
function uploadFile({target}) {

  if (target.files.length) {

    var file = target.files[0];
    uploadInput.value = ""// So we can upload the same file again, if required.

    if (!file) {
      setMessage("Please choose a file!");
      return;
    }

    var formData = new FormData();
    formData.append("file", file);

    let AJAX = new XMLHttpRequest();
    AJAX.onreadystatechange = function() {
      if (this.readyState == 4) setMessage(this.responseText);
    };

    AJAX.open("POST""upload", true);
    AJAX.send(formData);
  }
}


// Control buttons..
function goHome() {
  let GETS = makeGETParams("", 1);
  setMessage(loadingMsg);
  goNow(GETS);
}

function goPage(index) {
  let GETS = makeGETParams("", index);
  setMessage(loadingMsg);
  goNow(GETS);
}

function toggleThumbs() {
  let GETS = makeGETParams("thumbs");
  setMessage(loadingMsg);
  goNow(GETS + "&thumbs=" + (thumbView ? false : true).toString());
}

// We skip loading a lot of code if we're not doing PoP.
// Also, we don't want to reload the page unless it's necessary. So..
function togglePoP() {

  doPoP = doPoP ? false : true;
  let GETS = makeGETParams("pop");

  // PoP code not loaded. Re-load the page with PoP code..
  if (doPoP && !PoPLoaded) {

    setMessage(loadingMsg);
    goNow(GETS + "&pop=true");

  // PoP code already loaded. We are simply switching..
  } else {

    let popbutt = document.getElementById("popbutt");

    if (doPoP) {
      window.history.replaceState("object""", GETS + "&pop=true");
      popbutt.title = "disable pop-up previews";
      popbutt.innerHTML = "-";
//TODO proper hover handlers and here enable PoP immediately on switch?
    } else {
      window.history.replaceState("object""", GETS + "&pop=false");
      popbutt.title = "enable pop-up previews (P)";
      popbutt.innerHTML = "+";
      hidePoP(true);
    }
  }
}



function setPerPage(newVal) {
  let GETS = makeGETParams("per-page");
  setMessage(loadingMsg);
  goNow(GETS + "&per-page=" + newVal);
}

// We don't /need/ to keep this parameter in the GET query. We could set-and-forget. But, no.
function setThumbWidth(newVal) {
  let GETS = makeGETParams("width");
  setMessage(loadingMsg);
  goNow(GETS + "&width=" + newVal);
}

function goNow(url="/") {
  if (streamOpen) document.getElementById("frame").remove();
  setTimeout( function() { location.href = url; }, 200);
}

)HTML5");

/*
  We could just slam a second (and subsequent) set of parameters onto the end of the query string,
  which would then become default (by dint of beautiful coding logic, no doubt), but..

    &width=200&width=250&width=300&width=350

  ..or whatever, is inelegant and irks me in ways that are difficult to quantify. Messy.

  So instead, we break up the current URL and reconstruct it with only the necessary parts.

  In a few minutes, we have saved one or more human beings the toil of messing with *that* address
  bar. Good enough for me.

  This handy function is also used to strip arbitrary parameters from a URL, e.g. "
&prefs=true"
  when the state of the interface changes, so we can restore after a refresh/reload.

  The URL comes back with the "
ignore"d parameter removed.

  This beats trying to construct GET strings pre-button-markup (to insert into some
  location.href=MONSTER). Here everything happens dynamically, based on current input. Then all we
  need are simple wrapper functions for each control (see directly above).

  NOTE: This function is designed to accept multiple ignore parameters at-once. Reason: If you were
  to instead send three commands, their rapidity would (in the future) trigger a security alert. So
  we make one SINGLE change to the location history only.

*/

  webPage.concat(R"
HTML5(

function makeGETParams(ignore="", start) {

  let url = new URL(window.location.href);
  var newGET;

  // We always start with ?start=
  if (start != undefined) {
    newGET = "?start=" + start;
  } else {
    newGET = ( url.searchParams.get("start") ) ? "?start=" + url.searchParams.get("start") : "?start=1";
  }

  // Regular settings and features.. (null is falsy, all good)
  if (ignore.indexOf("thumbs") == -1 && url.searchParams.get("thumbs") ) newGET += "&thumbs=" + url.searchParams.get("thumbs");
  if (ignore.indexOf("pop") == -1 && url.searchParams.get("pop") ) newGET += "&pop=" + url.searchParams.get("pop");
  if (ignore.indexOf("per-page") == -1 && url.searchParams.get("per-page")) newGET += "&per-page=" + url.searchParams.get("per-page");
  if (ignore.indexOf("width") == -1 && url.searchParams.get("width") ) newGET += "&width=" + url.searchParams.get("width");

  // Interface States..
  if (ignore.indexOf("prefs") == -1 && url.searchParams.get("prefs") ) newGET += "&prefs=" + url.searchParams.get("prefs");
  if (ignore.indexOf("slideshow") == -1
                          && url.searchParams.get("slideshow") ) newGET += "&slideshow=" + url.searchParams.get("slideshow");

  // Live Stream
  if (ignore.indexOf("play") == -1 && url.searchParams.get("play") ) newGET += "&play=" + url.searchParams.get("play");
  if (ignore.indexOf("live") == -1 && url.searchParams.get("live") ) newGET += "&live=" + url.searchParams.get("live");
  if (ignore.indexOf("fit") == -1 && url.searchParams.get("fit") ) newGET += "&fit=" + url.searchParams.get("fit");

  // Floating live view..
  if (ignore.indexOf("floating") == -1
                              && url.searchParams.get("floating") ) newGET += "&floating=" + url.searchParams.get("floating");
  if (ignore.indexOf("floatX") == -1 && url.searchParams.get("floatX") ) newGET += "&floatX=" + url.searchParams.get("floatX");
  if (ignore.indexOf("floatY") == -1 && url.searchParams.get("floatY") ) newGET += "&floatY=" + url.searchParams.get("floatY");

  return newGET;
}
)HTML5");

// I only break here to prevent it showing up in Kate's brain-dead function list.


// If a prefs message arrives after you close the prefs panel, it's okay, the message will appear in
// the main window, and so on. setMessage() is flexible like that.
  webPage.concat(R"
HTML5(

function setMessage(newMessage, myTime = webMessageTime) {

  messageShowing = true;
  if (newMessage.indexOf(loadingMsg) != -1) myTime = 10000;

  // Show failure messages in red (or whatever you set in CSS)..
  if (/fail/i.test(newMessage)) newMessage = "<span class='warning'>" + newMessage + "</span>";

  // Prefs first, as they may be /over/ the slideshowhow.
  if (prefsOpen) {
    prefsMessage.innerHTML = newMessage;
    clearMessage(myTime);
    return;
  }
  if (slideShowOpen) {
    slideName.innerHTML = newMessage;
    clearMessage(myTime);
  } else {
    // We do not want you to miss this.
    document.getElementById("controller").scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
    msgTitle.innerHTML = newMessage;
    clearMessage(myTime);
  }
}
)HTML5");

// Clear the message after a time and set it back to the default message
// (or file name), which may have updated info (i.e. after file deletions).
// We use a global variable for the timer id so we can clear it before re-use.
// This enables you to see the most recent message for the /entire/ delay time.
// In fact, all timers have their own id's, so they also don't interfere with each other in any way.

  webPage.concat(R"
HTML5(

function clearMessage(myTime=0) {
  if (prefsOpen) {
    clearTimeout(prefsMsgTimer);
    prefsMsgTimer = setTimeout( function() {
      prefsMessage.innerHTML = "";
      messageShowing = false;
    }, myTime);
    return;
  }
  if (slideShowOpen) {
    clearTimeout(slideMsgTimer);
    slideMsgTimer = setTimeout(showSlide, myTime);
    messageShowing = false;
  } else {
    clearTimeout(msgTimer);
    msgTimer = setTimeout( function() {
      setDefaultMessage();
      messageShowing = false;
    }, myTime);
  }
}

function setDefaultMessage() {
  msgTitle.innerHTML = ImgRange + "&nbsp(" + theImages.length + " " + imgTallyName + ( (theImages.length != 1) ? "s" : "") + ")";
}

)HTML5");

  // This is where we calculate bottomOfThumbs and rightOfFileList, for gallery mode.
  webPage.concat(R"
HTML5(

function sizeUpdate() {
  donePoPMSG = false;
  if (thumbView) {
    thumbHeight = (theImages) ? theImages[0].clientHeight : 0;
    bottomOfThumbs = document.getElementById("end").getBoundingClientRect().top + thumbHeight;
  } else { // This will throw an error if there are no files.. Meh.
    rightOfFileList = document.getElementsByClassName("filelist")[0].getBoundingClientRect().right;
  }

  if (streamOpen) {
    let live = document.getElementById("frame");
    if (live.getBoundingClientRect().left > window.innerWidth || live.getBoundingClientRect().top > window.innerHeight) {
      setMessage("Live Stream is out of view!");
    }
  }
}


// Enable swipe actions for touch device.

let touchstartX = 0;
let touchmoveX = 0;
let touchendX = 0;
let touchstartY = 0;
let touchmoveY = 0;
let touchendY = 0;

function processSwipe(event) {

  // Swipe UP to exit slideshow..
  let yDelta = touchstartY - touchendY;
  if ( slideShowOpen && ( yDelta > (touchendX - touchstartX) && yDelta > (touchstartX - touchendX) ) ) {
    closeSlideShow(true);
    return;
  }

  // Swipe for previous / next slide..
  if (slideShowOpen && !prefsOpen) {

    if (touchendX < touchstartX) {
        if (touchToggleAutoPlay) {
          toggleAutoPlay();
          touchToggleAutoPlay = false;
        } else {
          userNextSlide(true);
        }
        return;
    }
    if (touchendX > touchstartX) {
        if (touchToggleAutoPlay) {
          toggleAutoPlay();
          touchToggleAutoPlay = false;
        } else {
          userPreviousSlide(true);
        }
        return;
    }

)HTML5");

  // Sadly we can't yet reliably detect hover states on touch devices (though tab+pen can *sometimes*
  // work great), so.. Swipe in any direction on a thumbnail to bring up the pop-up preview.
  // This relies on the fact that no human can swipe in a perfectly straight line. Leave that to the robots!
  // This also means a two-finger tap works great, too. Enjoy.

  webPage.concat(R"
HTML5(

  } else if (thumbView && doPoP) {
    for ( let i = 0 ; i < theImages.length ; i++ ) {
      if ( event.srcElement.id !== "PoP" && theImages[i].src == event.srcElement.src ) { // Was that swipe over a thumbnail?
        showPoP(theImages[i].src.split("/").pop(), true, true);
        return;
      }
    }
  }

  // Swipe left and right on PoP to view the previous/next image, like a mini-slideshow.
  // You can do this on the desktop, too. Left and right arrow keys when PoP is sticky.

  if (doPoP) {

    if ( event.srcElement.id == "PoP" && PoPIMG.src == event.srcElement.src ) { // Was that swipe over PoP?
      for ( let i = 0 ; i < theImages.length ; i++ ) {
        if (theImages[i].src == PoPIMG.src) { // Which image is in the pop?

          // Next PoP..
          if (touchendX < touchstartX) {

            if (loopPoP) {
              if ( i === iLinks.length-1 ) i=-1;
            } else {
              if ( i === theImages.length-1 ) {
                pulseLink(i);
                return;
              }
            }
            showPoP(theImages[i+1].src.split("/").pop(), false, true); // Load the next image
            return;
          }

          // Previous PoP
          if (touchendX > touchstartX) {

            if (loopPoP) {
              if ( i === 0 ) i = theImages.length;
            } else {
              if ( i === 0 ) {
                pulseLink(i);
                return;
              }
            }
            showPoP(theImages[i-1].src.split("/").pop(), false, true, false); // Load previous image
            return;
          }
        }
      }
    }
  }
}


// This is a nice way to group related functionality..

var motion = {

  start: function (event) {
    touchToggleAutoPlay = false;
    touchstartX = event.changedTouches[0].screenX;
    touchstartY = event.changedTouches[0].screenY;
    if (event.touches.length == 2) touchToggleAutoPlay = true;
    if (event.touches.length == 3){
      toggleFastAFMode(true);
      resetSlideTimer();
      return;
    }
    touchStarted = true;
  },

  move: function (event) {

    if (!touchStarted) return;

    // Get the touch's current co-ordinates..
    touchmoveX = event.changedTouches[0].screenX;
    touchmoveY = event.changedTouches[0].screenY;

    // Basic sliding effect between images, for usability / feedback..
    // This half of the code moves the current image under the swiping finger.
    // See previousSlide() / nextSlide(above) for the other half of the magic;
    // the next image sliding in from the other side; using JavaScript's "animate" API.

    // Swiping up (exit slideshow)..
    if (touchmoveY < touchstartY) {
      if (slideShowOpen && !prefsOpen) {
        slide.style.top = "calc( 50% + " + (touchmoveY - touchstartY) / 2 + "px )";
      }
    }

    // Swiping left..
    if (touchmoveX < touchstartX) {
      if (slideShowOpen && !prefsOpen) {
        slide.style.left = "calc( 50% - " + (touchstartX - touchmoveX) + "px )";
      }
    }
    // Swiping right..
    if (touchmoveX > touchstartX) {
      if (slideShowOpen && !prefsOpen) {
        slide.style.left = "calc( 50% + " + (touchmoveX - touchstartX) + "px )";
      }
    }
  },

  end: function (event) {

    // We don't check for "!slideShowOpen && !prefsOpen" but simply let processSwipe() deal with it.
    // There are only a few places one can usefully swipe, so if the user wants to waste their CPU swiping
    // in places where it won't "do" anything, we let them.

    if ((slideShowOpen && !prefsOpen) || thumbView || doPoP) {

      touchendX = event.changedTouches[0].screenX;
      touchendY = event.changedTouches[0].screenY;

      // Don't activate for touches (save image), only for actual swipes..
      let swipeDistance = window.innerWidth / 50;
      if ( ( touchendX > (touchstartX + swipeDistance) || touchendX < (touchstartX - swipeDistance) ) ||
            ( touchendY > (touchstartY + swipeDistance) || touchendY < (touchstartY - swipeDistance) ) ) {
        slide.style.left = "50%";
        processSwipe(event);
      }
      touchStarted = false;
    }
  }

}

// Listeners for swipe event..
document.addEventListener( "touchstart", motion.start, { capture: true } );
document.addEventListener( "touchmove", motion.move, { capture: true } );
document.addEventListener( "touchend", motion.end, { capture: true } );

)HTML5");


// HotKeys, e.g. arrow keys to move rapidly between slides, and more.
// Note: The direction is reset to forward when the slideshow opens.
//
// <rant>If you put these integers in quotes/apostrophes, you will break this functionality on many
// browsers. And rightly so. These are not Strings. I am no JavaScript "Expert", but even I know
// this. I just wish all the so-called "Experts" did, too. The waste! THINK OF THE CHILDREN!!</rant>
//
// Handy tool: https://www.toptal.com/developers/keycode

  webPage.concat(R"
HTML5(

document.body.addEventListener("keydown", (event) => {
// console.warn("MAIN keydown -------------->event: %o\n%O", event, event); //debug

  // , (comma)
  // This enables you to get to settings from the main window
  // as well as from inside the slideshow (maybe to change the time).
  if (event.keyCode == 188) {
    prefsOpen ? closePrefs() : openPrefs();
    return;
  }

  // SPACEBAR
  // Inside a slideshow, starts and stops the slideshow playing.
  // From the main screen, starts an auto-play slideshow. Add Ctrl to go full-screen.
  if (event.keyCode == 32) {
    if (!doPoP && !thumbView) return; // No images have been cached. SlideShow not possible.
    if (slideShowOpen) {
      toggleAutoPlay();
    } else {
      openSlideShow();
      slideShowPlaying = true;
      autoPlay(false);
    }
    return;
  }

  // Delete (or BackSpace, for devices with no Del/Delete key)  ---> DELETE Current Image.
  if (event.keyCode == 46 || event.keyCode == 8) {
    if (slideShowOpen) {
      deleteImage(sBaseName);
    } else {
      deletePoPImage();
    }
    return;
  }

  // "F1" or "H" for help..
  if (event.keyCode == 112 || event.keyCode == 72) {
    event.preventDefault();
    helpOpen ? closeHelp() : openHelp();
    return;
  }

  // "P" to toggle PoP-Up Previews..
  // Only if PoP code isn't loaded do we reload the page.
  if (event.keyCode == 80 ) {
    togglePoP();
    return;
  }

  // "L" to toggle live stream preview from anywhere..
  if (event.keyCode == 76) {
    streamOpen ? closeLiveStream() : openLiveStream();
    return;
  }

  // "R" to rotate live view..
  if (event.keyCode == 82) {
    if (streamOpen) rotateStream();
    return;
  }

  // "." Snap now..
  if (event.keyCode == 190) {
    takeSnap();
    return;
  }

  // "T" to toggle thumbnails. We will reload the page.
  if (event.keyCode == 84) {
    document.getElementById("thumbsbutt").click();
    return;
  }

  // (apostrophe) Toggle Video Recording.
  if (event.keyCode == 192) {
    toggleRecording();
    return;
  }

  // "[" Load Previous Page.
  if (event.keyCode == 219) {
    document.getElementById("previousPage").click();
    return;
  }

  // "]" Load Next Page.
  if (event.keyCode == 221) {
    document.getElementById("nextPage").click();
    return;
  }

  if (fAF) {

    // Up
    if (event.keyCode == 38) {
      fAFTime += 0.025;
      if (fAFTime > fAFStore) setMessage("Regular slideshow time is faster!");
      slideTime = fAFTime;
      return;
    }
    // Down
    if (event.keyCode == 40) {
      fAFTime -= 0.025;
      if (fAFTime < 0) fAFTime = 0;
      slideTime = fAFTime;
      return;
    }
  }



  // UP Arrow
  if (event.keyCode == 38) {
    if (slideShowOpen) {
      if (upDownRev) {                // Again, optional.
        userNextSlide();
      } else {
        userPreviousSlide();
      }
    } else if (doPoP) {
      loadNextPoP(true, true);
    }
    return;
  }

  // Left
  if (event.keyCode == 37) {
    if (slideShowOpen) {
      userPreviousSlide();
      slidesRev = true;
    } else if (doPoP) {
      loadNextPoP(true, true);
    }
    return;
  }


  // DOWN Arrow
  if (event.keyCode == 40) {
    if (slideShowOpen) {
      if (upDownRev) {
        userPreviousSlide();          // <-- Backwards
      } else {
        userNextSlide();              // <-- Or forwards. Your call.
      }
    } else if (doPoP) {
      loadNextPoP(false, true);
    }
    return;
  }

  // Right Arrow
  if (event.keyCode == 39) {
    if (slideShowOpen) {
      userNextSlide();
    } else if (doPoP) {
      loadNextPoP(false, true);
    }
    return;
  }


  if (!slideShowOpen) return;

  // "S" Save image from slideshow..
  if (event.keyCode == 83) {
    saveImage();
    return;
  }

  // "F" Toggle fAF Mode..
  if (event.keyCode == 70) {
    event.preventDefault();
    toggleFastAFMode(true);
    resetSlideTimer();
    return;
  }

  // Enter (exit slideshow mode)
  if (event.keyCode == 13) {
    event.preventDefault();
    closeSlideShow();
    return;
  }

}, true);



// Detect "special" clicks..
window.onclick = function (event) {

  // Click to immediately clear message.. (there are sometimes styles in there, so...)
  if ( messageShowing && ( event.target.parentNode == prefsMessage ||event.target.parentNode.parentNode == prefsMessage
   || event.target.parentNode == msgTitle || event.target.parentNode.parentNode == msgTitle ) ) {
    event.preventDefault();
    clearMessage(0);
    return;
  }

  if ( ( thumbView || doPoP ) && event.target == msgTitle) {
    openSlideShow();
    return;
  }




  // Prefs can be on top of slideshow, so we put slideshow first and then everything collapses nicely.
  if (slideShowOpen) {
    let slideBox = document.getElementById("slide");
    if (event.target.contains(slideBox) && event.target !== slideBox) {
      closeSlideShow();
      return;
    }
  }

  if (prefsOpen) {
    let prefsBox = document.getElementById("pref-box");
    if (event.target.contains(prefsBox) && event.target !== prefsBox) {
      closePrefs();
      return;
    }
  }

  if (streamOpen && !floating) {
    let live = document.getElementById("frame");
    if (event.target.contains(live) && event.target !== live) {
      closeLiveStream();
      return;
    }
  }

  // This is the top-most element of all..
  if (helpOpen) {
    let helpBox = document.getElementById("help-box");
    if (event.target.contains(helpBox) && event.target !== helpBox) {
      closeHelp();
      return;
    }
  }

}


// When you resize the browser window.. ("mostly" reliable)
window.onresize = () => { sizeUpdate(); }

// DOM has completed loading. Let's "do stuff"..
document.addEventListener("DOMContentLoaded", function (event) {

  // In thumbnails View, or List View when PoP is enabled; you can run the slideshow..
  if (thumbView || doPoP) {
    msgTitle.style.cursor = "zoom-in";
    slideShow =  document.getElementById("slideshow");
    slide =  document.getElementById("slide");
    slideName = document.getElementById("slide-name");
    imgSaveLink = document.getElementById("imgSaveLink");
    // Rather than hard-code defaults, we store the /previous/ colors, as these might have been altered in CSS, by you.
    prevColor = slideName.style.color;
    prevBGColor = slideShow.style.background;
  }

  if (thumbView) {

    // The images are already loaded as thumbnails so it's easy to create a
    // "live Collection" of image tags we can iterate for our slideshow..
    theImages = document.getElementById("thumbnails").getElementsByTagName("img");
    iLinks = document.getElementById("thumbnails").getElementsByTagName("div");

  } else if (doPoP) {

    // PoP enabled in List View.. Let's preload all the image files right now..
    // An (optional) spinning reload icon will let you know this is happening.

    iLinks = document.getElementById("listing").getElementsByTagName("a");
    let plDiv = document.getElementById("preloaders");
    let pl = [];

    for (let i = 0 ; i < iLinks.length ; i++) {

      if (iLinks[i].href.indexOf(fileEXT) !== -1) {
        // pl[i] = new Image(); // BORING!
        pl[i] = document.createElement("img");
        pl[i].style.display = "none";
        pl[i].src = iLinks[i].href;
        pl[i].id = "pl-" + iLinks[i].href.split("/").pop();
        plDiv.appendChild(pl[i]);
      }
    }

    // Create a live collection from our div of newly-created hidden preloader images..
    // Hey! We could use this for a slideshow..
    theImages = plDiv.getElementsByTagName("img");
  } else {
    // No PoP. (we still need this for counting the current number of images/links on the page)
    theImages = document.getElementById("listing").getElementsByTagName("div");
  }

  // This only kicks-in when you (potentially) have no visual indicator of background resources
  // (images) loading. In other words, in List View when PoP is enabled.
  if (loadingIcon && !thumbView && doPoP) {

    // We use CSS trickery to slowly spin the reload character inside the button..
    let reloadIcon = document.getElementById("reloading");
    reloadIcon.style.rotate = "100turn";
    // Very few will see the full animation. Good.
    reloadIcon.style.transition = "150s ease";
    // That's 100 turns over 150 seconds, speeding up then eventually slowing down.

    // Wait for all the image resources to load in the background..
    let imgs = document.images, len = imgs.length, counter = 0;

    // A nice clean solution I hope works in your browser. From..
    // https://stackoverflow.com/questions/11071314/javascript-execute-after-all-images-have-loaded
    function incrementCounter() {
      counter++;
      if (counter == len) {
        // All images have loaded. Reset loading icon..
        reloadIcon.style.transition = "unset";
        reloadIcon.style.rotate = "none";
      }
    }
    [].forEach.call( imgs, function(img) {
      if (img.complete) {
        incrementCounter();
      } else {
        img.addEventListener("load", incrementCounter, false);
      }
    } );
  }
)HTML5");
  if (amRecording) { webPage.concat("
startRecordingNotify();"); }
  webPage.concat(R"
HTML5(

  // File uploads..
  uploadInput = document.getElementById("fileUpload");
  uploadInput.addEventListener("change", uploadFile);

  setIntermediateCheckBox(document.getElementById("darkmode"));

  // There was a prefs change that caused a reload. Re-open the prefs panel immediately.
  if (openWithPrefs) openPrefs();

  // This stays on the base layer..
  setDefaultMessage();

});

window.onload = () => {
  if (openWithSlides) {
    openSlideShow();
    if (autoPlaying) {
      toggleAutoPlay(false);
    }
  }
  if (amStreaming) openLiveStream();
  if (openWithHelp) openHelp();
};


</script>
</body>
</html>
)HTML5");

  // Spit out the finished page..
  server.send(200, _HTML_, webPage);
}



/*
  Request to delete a file.

  We use AJAX because it is faster and keeps delete commands out of the main GET parameters,
  which is a disaster waiting to happen.

  *after* you successfully delete an image, the thumbnail is removed from the web view.
  We do /not/ load any more images.

  You can delete *any* file you may have on the SD card with a request like so:

    http:/espcam/delete?file=myControllerPage.html


/delete                  */

void handleDeleteFile() {

  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  if (eXi) Serial.printf("
 Delete Request:");

  if (ejected) {

    sendEjected();
    return;

  } else if (!server.hasArg("
file")) {

    send500();
    return;

  } else {

    if (server.hasArg("
file")) {

      String postMessage = deleteFile(server.arg("
file"));

      if (eXi) Serial.println(postMessage);
      server.send(200, _TEXT_, postMessage);
    }
  }
}



/*
  AJAX Web Snap..

/snap                 */

void handleTakeSnapNow() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) {
    sendEjected();
    return;
  } else {
    if (webSnapResetsTimer) {
      mostRecentPic = 0;
    } else {
      if (!instantPic()) {
        server.send(200, _TEXT_, "
FAIL");
      }
    }
    server.send(200, _TEXT_, "
OK");
  }
}


/*
  Capture and display an image..

/capture                 */

void handleCapture() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) {
    sendEjected();
    return;
  } else {
    if (webSnapResetsTimer) {
      mostRecentPic = 0;
    } else {
      if (!instantPic()) {
        server.send(200, _TEXT_, "
FAIL");
      }
    }
    loadImage(mostRecentCapture);
  }
}

// Oh, don't be stupid!
void sendEjected() {
  LastMessage = "
 SD Card is ejected!";
  server.send(200, _TEXT_, "
FAIL!" + LastMessage);
}

void sendNotPlaying() {
  LastMessage = "
 Streaming SlideShow is not playing!!";
  server.send(200, _TEXT_, "
FAIL!" + LastMessage);
}

void sendAlreadyPlaying() {
  LastMessage = "
 Streaming SlideShow is already playing!!";
  server.send(200, _TEXT_, "
FAIL!" + LastMessage);
}


/*
  Full File list.
  Shows sizes and dates, performs space calculations, etc..
  Plain text output.

/list                 */

void handleFullFileList() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  bool quickListingsTMP = quickListings;
  quickListings = false;
  LastMessage = listDir(false, false, true); // if you switch mode now, tough!
  server.send(200, _TEXT_, LastMessage);
  quickListings = quickListingsTMP;
}


/*
  File list, at whatever your current quickList Setting is.

  So therefore, also acts as an indicator as to what mode you are in. However..

    command: iq

  Is definitely a quicker way to do that.

/l                 */

void handleFileList() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  LastMessage = listDir(false, false, true);
  server.send(200, _TEXT_, LastMessage);
}


/*
  Quick File List

/ql                        */

void handleQuickFileList() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  LastMessage = quickList();
  server.send(200, _TEXT_, LastMessage);
}

/*
  Quick File List (All files)

/qla                          */

void handleQuickAllFileList() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  LastMessage = quickList(true, true);
  server.send(200, _TEXT_, LastMessage);
}




/*
  AJAX set Snap Interval Time..

/interval                   */

void handleIntervalChange() {  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  String type = "
second";
  uint64_t multiplier = 1000;
  uint16_t newInterval = (delayTime / 1000);

  for (uint8_t i = 0; i < server.args(); i++) {

    newInterval = server.arg(i).toInt();

    if (server.argName(i) == "
seconds") {
      break;
    }
    if (server.argName(i) == "
minutes") {
      multiplier = MINUTE_MILLIS;
      type = "
minute";
      break;
    }
    if (server.argName(i) == "
hours") {
      multiplier = HOUR_MILLIS;
      type = "
hour";
      break;
    }
    if (server.argName(i) == "
days") {
      multiplier = DAY_MILLIS;
      type = "
day";
      break;
    }

  }

  delayTime = newInterval * multiplier;
  prefs.putULong64("
d", delayTime);

  char plural = '\0';
  if (newInterval != 1) plural = 's';
  char buffer[64] = {'\0'};

  // We display the *actual* interval time, calculated from our *current* saved value; /not/ the user input.
  sprintf(buffer, "
Snap interval time set to %llu %s%c", (delayTime / multiplier), type.c_str(), plural);

  server.send(200, _TEXT_, "
OK" + (String)buffer);
  if (eXi) Serial.printf("
 %s\n", buffer);
}

//TODO make this a wrapper and re-use inner function  - called from inside loop()



/*
   Check current snap interval..

/getint                   */

void handleGetInterval() {
   if (eXi) Serial.printf("
 HTTP Request: Main Page for client @ %s%s\n",            server.client().remoteIP().toString().c_str(), server.uri().c_str());
  server.send(200, _TEXT_, "
OK Snap interval time: " + convertSeconds(delayTime/1000));
}


/*
   Generic AJAX settings handler..

   Technically, it would be easy enough to get this to handle multiple settings-at-once.
   But we don't.


/settings                   */

void handleCameraSettings() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  String rebootMsg = "
\nTL-CAM will now reboot. Please wait..";
  String rangeFailMsg = "
Fail! Requested setting is out of range!";
  String reloadMsg = "
\n (reload the page to see the changes)";
  String csMsg, bpl = "
", newSetting = ""; //, newCommand = "";
  uint16_t newSettingInt;

  for (uint8_t i = 0; i < server.args(); i++) {

    newSetting = server.arg(i);
    newSetting.trim();
    newSettingInt = newSetting.toInt();

    if (newSettingInt != 1) bpl = "
s";

    // Set LED Flash Brightness..
    if (server.argName(i) == "
ledb") {
      // If it's not obvious, we use *unique* strings that are easy to search for in a long sketch.
      ledBrightness = newSettingInt; // The conversion to 8-bit will handle any out-of-range user malarkey
      csMsg = "
LED brightness set to " + newSetting;
      prefs.putUChar("
b", ledBrightness);
      flashLED(720);
      break;
    }

    // Max Frames-Per-Second can also be set here (this handles the prefs panel commands)..
    // We use the sensor API to set this. It handles error-checking, saving to NVS, etc..
    if (server.argName(i) == "
mfps") {
      max_fps = newSettingInt;
      csMsg = "
Setting Streaming Frame Limit to " + newSetting + " FPS: " + setSensorParameters("max_fps", newSetting, false);
      break;
    }

    // Set overWrite flag..
    // Only works with numeric sequences, not timestamped file names.
    // Be aware of the ramifications!
    if (server.argName(i) == "
ovw") {
      overWrite = (newSetting == "
true") ? true : false;
      csMsg = "
Overwrite mode " + (String)(overWrite ? "enabled" : "disabled");
      prefs.putBool("
o", overWrite);
      break;
    }


    // Set timeStampFileNames flag..
    // Switching this in the middle of a set of images may cause interface idiosyncrasies,
    // though we do try to work around this.
    if (server.argName(i) == "
tsf") {
      timeStampFileNames = (newSetting == "
true") ? true : false;
      csMsg = "
timestamped file names " + (String)(timeStampFileNames ? "enabled" : "disabled");
      prefs.putBool("
t", timeStampFileNames);
      break;
    }


    // Web-Related Settings..

    // Set webSnapResetsTimer flag..
    if (server.argName(i) == "
wsr") {
      webSnapResetsTimer = (newSetting == "
true") ? true : false;
      csMsg = "
Web snap resets timer: " + (String)(webSnapResetsTimer ? "true" : "false");
      prefs.putBool("
w", webSnapResetsTimer);
      break;
    }


    // Web Interface Settings..


    /*
      Fortunately, web browsers accept cookies from AJAX responses, so..

      Cookies!

      We don't save these preference to NVS, but instead set a cookie in the browser.
      This enables different clients to keep their own individual setting. We want this.

      One can set a maximum of 60 cookies, or a total cookie size of maximum
      4093 bytes,  whichever comes first. We won't need that many.
                                                                              */


    /*
      Set Dark Mode flag..


      NOTE: If you don't set the Path ("
Path=/") in the cookie, Firefox et al will reject the cookie.

      NOTE HAHA: You can even omit the ';' delimiter and just slam Path=/ onto the *value* and
                 Firefox will happily accept the cookie.. *ahem* Firefox Devs, again.

                 Someone please report this bug.

                                    */


    if (server.argName(i) == "
dms") {
      csMsg = "
Dark mode: " + (String)((newSetting == "true") ? "enabled" : "disabled") + reloadMsg;
      setCookie("
darkmode", newSetting);
      break;
    }

    // Automatic Dark Mode..
    if (server.argName(i) == "
dma") {
      csMsg = "
Automatic Dark mode: " + (String)((newSetting == "true") ? "enabled" : "disabled");
      setCookie("
autodark", newSetting);
      getNightTimeStatus();
      if ( newSetting == "
true" ) {
        if ( ( !darkMode && nightTime ) || ( darkMode && !nightTime ) ) csMsg += reloadMsg;
      } else {
        if ( ( amDark && !darkMode ) || ( !amDark && darkMode ) ) csMsg += reloadMsg;
      }
      break;
    }

    // Set image caching flag..
    if (server.argName(i) == "
cai") {
      cacheImages = (newSetting == "
true") ? true : false;
      csMsg = "
Image caching " + (String)(cacheImages ? "enabled" : "disabled");
      setCookie("
cacheimg", newSetting);
      break;
    }


    // Set Image Caching Time..
    if (server.argName(i) == "
cst") {
      if (newSettingInt >= 1) {
        cacheTime = newSettingInt;
        csMsg = "
Image cache time set to " + newSetting + " minute" + bpl;
        prefs.putUShort("
m", cacheTime);
        setCookie("
cachetime", (String)cacheTime);
      } else {
        csMsg = rangeFailMsg;
      }
      break;
    }


    // SlideShow Time..
    if (server.argName(i) == "
sst") {
      csMsg = "
SlideShow time set to " + newSetting + " second" + bpl;
      setCookie("
slidetime", newSetting);
      slideTime = newSettingInt;
      break;
    }

    // Animated Loading Icon..
    if (server.argName(i) == "
aii") {
      setCookie("
rotateicon", newSetting);
      loadingIcon = (newSetting == "
true") ? true : false;
      csMsg = "
Rotating loading icon: " + (String)(loadingIcon ? "enabled" : "disabled");
      break;
    }

    // Sticky Pop..
    if (server.argName(i) == "
skp") {
      setCookie("
stickypop", newSetting);
      stickyPoP = (newSetting == "
true") ? true : false;
      csMsg = "
Sticky PoP-Up preview: " + (String)(stickyPoP ? "enabled" : "disabled");
      break;
    }

    // Looping Pop..
    if (server.argName(i) == "
lpp") {
      setCookie("
looppop", newSetting );
      loopPoP = (newSetting == "
true") ? true : false;
      csMsg = "
Looping PoP-Up Previews: " + (String)(loopPoP ? "enabled" : "disabled");
      break;
    }

     // Extra Spaces In List Views..
    if (server.argName(i) == "
xsp") {
      setCookie("
listspaces", newSetting);
      spacesInListView = (newSetting == "
true") ? true : false;
      if (thumbView) reloadMsg = "
"; // Only refresh page in list view.
      csMsg = "
Extra space in list views: " + (String)(spacesInListView ? "enabled" : "disabled") + reloadMsg;
      break;
    }

     // Stream AND record simultaneously..
    if (server.argName(i) == "
tws") {
      setCookie("
twinstream", newSetting );
      streamANDRecord = (newSetting == "
true") ? true : false;
      csMsg = "
Simultaneous Streaming AND Recording: " + (String)(streamANDRecord ? "enabled" : "disabled");
      break;
    }

    // Zoomed Mouse Movement Acceleration..  (zmAcelleration)
    if (server.argName(i) == "
maz") {
      setCookie("
zmaccel", newSetting );
      csMsg = "
Zoomed Mouse Movement Acceleration set to: " + newSetting;
      break;
    }


    /*
      Background Cookies

      These are set from /events/, as opposed to user clicks.
                                                              */


     // Seen the tinyStream HotKeys warning.. (seenTWarn)
    if (server.argName(i) == "
stw") {
      csMsg = "
";
      setCookie("
tinywarn", "seen" );
      break;
    }

     // Seen the prefs (settings are immediate) warning.. (seenPWarn)
    if (server.argName(i) == "
spw") {
      csMsg = "
";
      setCookie("
prefwarn", "seen" );
      break;
    }


  }

  // On success, we lop off the first two characters ("OK") with JavaScript to create the final displayed message.
  server.send(200, _TEXT_, (csMsg != rangeFailMsg) ? "
OK " + csMsg : rangeFailMsg);
  // if (newCommand != "") xCommand = newCommand; // Not required, for now.
  if (eXi) Serial.printf("
 %s\n", csMsg.c_str());
}


/*
  Handle image settings..

/sensorset                 */

void handleImageSettings() {
    if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  String ret = setSensorParameters(server.argName(0), server.arg(0));
  if (ret.substring(0,2) != "
OK") {
    server.send(500, _TEXT_, "
Command Failed");
    return;
  }
  server.send(200, _TEXT_, ret);
}




/*
 Handle poll for reboot status..

/rebooted                 */

void handleRebootStatus() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  server.send(200, _TEXT_, "
OK");
}



void handleBackupNVS() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  String msg = "
OK Configuration Backup: ";
  server.send(200, _TEXT_, msg + saveSensorConfig("
backup"));
}

void handleRestoreNVS() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  String msg = "
OK Configuration Restore: ";
  server.send(200, _TEXT_, msg + loadSensorConfig("
backup"));

}


// So let's create a web console..

// /*
// /console             */

// void sendWebConsole() {
//
//   // HTML you can edit..
//   #include "Console.h"
//
//   // WebConsole is the String created in Console.h
//
//   // New clients get a list of commands..
//   String myClient = server.client().remoteIP().toString();
//   if (knownClients.indexOf(myClient) == -1) {               // Is this client known yet?
//     WebConsole.replace("<!--{insert}-->", getCommands());   // Never forget the usefulness of the lowly HTML comment.
//     knownClients += myClient;                               // Add this client to the list of "known" clients.
//   }
//   server.send(200, _HTML_, WebConsole);
//   if (eXi) Serial.printf(" HTTP Request: WebConsole for client @ %s\n", myClient.c_str());
// }



/*
  Send the last message.
  Handle the AJAX request that comes in directly after you send /any/ command from the web console.

  This is beautiful and enables us to use the web as a proper console, with responses right there on
  your phone/tablet/PC/whatever. If there is no message available (e.g. they sent a blank command)
  we print out the current commands list.

                          */

/*
/LastMessage             */

void handleLastMessage() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  server.send(200, _TEXT_, (LastMessage == "
") ? getCommands() : LastMessage);
  LastMessage = "
";
}



/*
  AJAX Eject SD Card..

/eject               */

void handleEjectSD() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) {
    LastMessage = "
Fail!\n SD Card is already ejected!";
  } else {
    SD_MMC.end();
    LastMessage = "
SD Card Stopped.\n Your SD Card can now be safely removed.";
    ejected = true;
  }

  server.send(200, _TEXT_, "
OK " + LastMessage);
  if (eXi) Serial.printf("
 %s\n", LastMessage.c_str());
}


/*
  AJAX Insert/Start SD Card..

/insert               */

void handleInsertSD() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (!ejected) {
      LastMessage = "
Fail!\n SD Card is not ejected!";
  } else {
    if (startMicroSD()) {
      String stateA = "
resumed";
      if (!autoSnap) stateA = "
enabled (note: auto-snap is currently disabled)";
      LastMessage = "
SD Card started successfully.\n Image acquisition " + stateA;
      ejected = false;
    } else {
      LastMessage = "
SD Card Error. Please try again.";
    }
  }

  server.send(200, _TEXT_, "
OK " + LastMessage);
  if (eXi) Serial.printf("
 %s\n", LastMessage.c_str());
}



/*
  AJAX SD Card info..

/info               */

void handleSDInfo() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (ejected) {
    sendEjected();
  } else {
    LastMessage = printSpace();
    server.send(200, _TEXT_, "
OK" + LastMessage);
  }
}



/*
  AJAX Memory info..

/memory               */

void handleMemoryInfo() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  server.send(200, _TEXT_, "
OK" + printMemoryInfo());
}


/*
  Serial Commands Help Page..

/help               */

void handleCommandsHelp() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  LastMessage = getCommands();
  server.send(200, _TEXT_, LastMessage);
}


/*
  404.. Or Not.

  All image URL's get caught and handled by this function.

  If it's not an image URL (or does not exist) it is passed along to the commands handler
  as a serial-over-web command. See:

    /console

  NOTE: If you send commands which have their own web handlers, e.g. "
list", that command will be
  intercepted by the web handler and NOT handled here**.

  Basically, any URI that doesn't have its own handler, is treated as a command, so if you point
  some HTTP-capable device (e.g. web browser) to:

    /foo

  The command "
foo" would be passed to the command interpreter. Of course there is no "foo" command
  so nothing would happen. Valid commands will leave their output in "
LastMessage", which /console
  picks up and displays below the command input.

  ** However, if required, you can force your command to be handled by the serial command mechanism
  by preceding the command with an asterisk, e.g..

    *list

                        */

void universalHandler() {

  // In case their are spaces and what-not in the file names *sigh*
  String thisURL = urlDecode(server.uri());

  // Remove asterisk from command, if it exists..
  if (thisURL[1] == '*') thisURL = (String)thisURL[0] + thisURL.substring(2);

  // It's not a real file, so we assume it is a command..
  if ( !SD_MMC.exists(thisURL.c_str()) ) { // c_str() required? nah!

    // Lop off preceding forward slash. Boom! Instant console command..
    xCommand = thisURL.substring(1);

    // Everything after the line-break (\n) only appears if you access the URI directly.
    // /console will move directly to displaying the output of /LastMessage
    // This is why, unless scripting, using /console is preferred over sending commands directly via URL.
    server.send(200, _TEXT_, xCommand + "
: OK\nSee /LastMessage for any output from your command.");
    if (eXi) Serial.printf("
 HTTP Request: xCommand: %s\n", xCommand.c_str());

  // This is an actual image file. Send it..
  } else if (thisURL.indexOf(fileEXT) != -1) {

    loadImage(thisURL);

  // Client attempted to access MJPEG stream directly by file name.
  // Redirect to more useful URI..
  } else if (thisURL.indexOf(streamFileName) != -1) {

    server.sendHeader("
Location", "/mjpeg");
    server.send(302, _TEXT_, "
");

  } else {

    if (eXi) Serial.printf("
 HTTP Request: Serving File: %s\n", thisURL.c_str());
    File file = SD_MMC.open(thisURL.c_str());
    if (!file) return;
    server.streamFile(file, "
text/html");

    //server.serveStatic("/", SD_MMC, thisURL.c_str()); // only works when set along with server.on() statements @ init
  }

}




/*

  A cute custom favicon for your TL-CAM

  It's a camera shutter with "
T", "L" and "C" in the centre.
  Rendered with 100% MATHEMATICS!*

  * Insert joke about me toiling all night with a protractor.

/favicon.ico         */

void handleFavicon() {
  String favicon = R"
SVGfavicon(<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"><svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="64.000000pt" height="64.000000pt" viewBox="0 0 64.000000 64.000000" preserveAspectRatio="xMidYMid meet"><g transform="translate(0.000000,64.000000) scale(0.050000,-0.050000)" fill="#888" stroke="none"><path d="M555 1266 c-91 -14 -156 -208 -155 -465 l1 -111 54 99 c66 119 298 256 506 298 l123 25 -77 51 c-122 80 -315 124 -452 103z"/><path d="M380 1225 c-134 -50 -308 -240 -332 -361 -15 -75 66 -171 259 -306 174 -122 188 -122 113 -1 -84 136 -80 449 9 626 29 58 18 68 -49 42z"/><path d="M920 1043 c-60 -20 -164 -64 -230 -99 l-120 -62 96 -1 c148 -2 445 -176 547 -322 l42 -61 12 66 c33 173 -89 501 -189 510 -27 3 -97 -12 -158 -31z"/><path d="M860 723 c84 -137 80 -449 -10 -628 -65 -132 203 20 310 175 124 180 93 256 -187 452 -174 122 -188 122 -113 1z"/><path d="M500 770 c0 -16 14 -30 30 -30 23 0 30 -26 30 -110 l0 -110 120 0 c93 0 120 7 120 30 0 22 -22 30 -80 30 -80 0 -80 0 -80 80 0 58 8 80 30 80 17 0 30 14 30 30 0 23 -24 30 -100 30 -76 0 -100 -7 -100 -30z"/><path d="M13 716 c-27 -145 79 -479 163 -511 42 -16 262 52 404 126 l130 67 -96 1 c-147 2 -450 178 -544 317 l-45 66 -12 -66z"/><path d="M816 480 c-64 -115 -336 -268 -520 -292 -94 -13 -99 -16 -64 -43 197 -147 517 -184 578 -65 38 74 74 291 66 404 l-6 94 -54 -98z"/></g></svg>)SVGfavicon";
  server.send(200, "
image/svg+xml", favicon);
}

// To change the color of the favicon, alter the "fill" value.
// Note: "color" is the technical term for web colours. Like "center" is, for the centre of web things.






/*
  Stream most recent capture to browser..

/recent             */

void handleRecent() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  File imgFile = SD_MMC.open(mostRecentCapture.c_str());
  if (imgFile) {
    if (eXi) Serial.printf("
 Streaming File: %s\n", mostRecentCapture.c_str());
    server.streamFile(imgFile, "
image/jpeg");
  } else server.send(404, _TEXT_, "
Image has been deleted.");
}


/*
  Sensor Status..

/sensor                          */

void handleSensorStatus() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  LastMessage = getSensorStatus();
  deJSONify(LastMessage);
  server.send(200, _TEXT_, LastMessage);
}

/*
  FULL Sensor Status..

/sensorx                          */

void handleSensorStatusEx() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  LastMessage = getSensorStatus(false, true);
  deJSONify(LastMessage);
  server.send(200, _TEXT_, LastMessage);
}

/*
  json version of sensor status
  used by controllers to set initial values for web controls

/status                      */

void handleESPSensorStatus() {
  if (eXi) Serial.printf("
 HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  LastMessage = getSensorStatus(true, true, true);
  server.sendHeader("
Access-Control-Allow-Origin", "*");
  server.send(200, "
application/json", LastMessage);
}


void deJSONify(String &string) {
  string.replace("
:", "");
  string.replace("
\"""");
}

/*
  Print out stored NVS Settings..

/nvs                 */

void handlePrintNVS() {
  if (eXi) Serial.printf(" HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  LastMessage = gatherPrefs(false, false);
  LastMessage += gatherSensorPrefs();
  server.send(200, _TEXT_, LastMessage);
}



// Decode strings from the web.. (from webserver example-ish)
String urlDecode(String text) {
  String decoded = "";
  char temp[] = "0x00";
  uint16_t len = text.length();
  uint16_t i = 0;
  while (i < len) {
    char decodedChar;
    char encodedChar = text.charAt(i++);
    if ( (encodedChar == '%') && (i + 1 < len) ) {
      temp[2] = text.charAt(i++);
      temp[3] = text.charAt(i++);
      decodedChar = strtol(temp, NULL, 16);
    } else {
      if (encodedChar == '+') {
        decodedChar = ' ';
      } else {
        decodedChar = encodedChar;  // Normal ASCII character
      }
    }
    decoded += decodedChar;
  }
  return decoded;
}



/*
  Compatibility functions (for "official" controllers)

  Taken from official example, "CameraWebServer.ino" (app_httpd.cpp). I fixed them up a bit and
  adapted them to work with webserver.h.

  To be frank, all this is a rabbit-hole you probably don't want to go down.

  Unless you do. There are a crazy amount of parameters which can be set on the average camera
  "sensor"; especially the OV3660 and OV5640 variants.

  I'm not going to re-invent the wheel for this stuff which, to be frank, I do not use.
  Time is too valuable. As a result, they have had only basic testing.

  Please let me know if you have any issues and I will do my best to fix them.

  The various APIs are fully exposed; you can get and set the various registers, play with pll and
  window resolutions and more. Any of the regular "official" controller pages should work just fine
  with TL-CAM, or feel free to code your own controller pages.

  NOTE: TL-CAM doesn't do face recognition, so any commands along those lines will be ignored.

  A set of ready-made controller pages is available along with this sketch, OV2640.html, OV3660.html
  and OV5640.html.

  See the Controllers directory next to this sketch for the current collection.

  To use: upload the controller page with TL-CAM's upload facility (in the prefs panel), then visit
  the page with your browser, e.g..

    http://espcam/OV5640.html

  You can obviously manually copy the file to your SD Card but the upload facility is easier,
  especially if you are /editing/ the controller page.

*/



/*
  Wrapper for "official"..

/control                       */

void handleESP32ControlWrap() {

  if (eXi) Serial.printf(" HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  String myVar, myVal;

  // We are simply stripping the redundant parameters so it works with TL-CAM's camera API.
  if (server.hasArg("var")) myVar = server.arg("var");
  if (server.hasArg("val")) myVal = server.arg("val");

  if (myVar != "" && myVal != "") {

    String ret = setSensorParameters(myVar, myVal);

    if (ret.substring(0,2) != "OK") {
      server.send(500, _TEXT_, "Command Failed");
      return;
    }
    server.send(200, _TEXT_, ret);

  } else {
    send500();
    return;
  }
}


/*
  Set Registers..

  Check your sensor's documentation for a (probably huge) list of what can be set. And where.

/reg                     */

void handleESP32SetReg() {

  if (eXi) Serial.printf("\n HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  if (server.arg("reg") == "" || server.arg("mask") == "" || server.arg("val") == "" ) { send500(); return; }

  int reg = server.arg("reg").toInt();
  int mask = server.arg("mask").toInt();
  int val = server.arg("val").toInt();

  if (eXi) Serial.printf(" Setting Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x\n", reg, mask, val);

  sensor_t *s = esp_camera_sensor_get();
  int8_t res = s->set_reg(s, reg, mask, val);
  s->set_reg(s, reg, mask, val);
  if (res) { send500(); return; }

  server.sendHeader("Access-Control-Allow-Origin""*");
  server.send(200, _TEXT_, "OK");
}



/*
  Get Register Value..

  It's a good idea to use this before you set a register,
  making a note of the current value.

/greg                     */

void handleESP32GetReg() {

  if (eXi) Serial.printf("\n HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());
  if (server.arg("reg") == "" || server.arg("mask") == "" ) { send500(); return; }

  int reg = server.arg("reg").toInt();
  int mask = server.arg("mask").toInt();

  sensor_t *s = esp_camera_sensor_get();
  int8_t res = s->get_reg(s, reg, mask);

// Serial.printf("res: %i\n", res); //debug

  if (res < 0) { send500("invalid response"); return; }

  char buffer[20];
  const char * val = itoa(res, buffer, 10);

  if (eXi) Serial.printf(" Getting Register: reg: 0x%02x, mask: 0x%02x, value: %s\n", reg, mask, val);

  server.sendHeader("Access-Control-Allow-Origin""*");
  server.send(200, _TEXT_, val);
}


//  /pll
void handleESP32pll() {

  if (eXi) Serial.printf("\n HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (server.arg("bypass") == "" || server.arg("mul") == "" || server.arg("sys") == "" || \
   server.arg("root") == "" || server.arg("pre") == "" || server.arg("seld5") == "" || \
   server.arg("pclken") == "" || server.arg("pclk") == "" ) { send500(); return; }

  int bypass = server.arg("bypass").toInt();
  int mul = server.arg("mul").toInt();
  int sys = server.arg("sys").toInt();
  int root = server.arg("root").toInt();
  int pre = server.arg("pre").toInt();
  int seld5 = server.arg("seld5").toInt();
  int pclken = server.arg("pclken").toInt();
  int pclk = server.arg("pclk").toInt();

  if (eXi) Serial.printf("Set Pll: bypass: %d, mul: %d, sys: %d, root: %d, pre: %d, seld5: %d, pclken: %d, pclk: %d\n", bypass, mul, sys, root, pre, seld5, pclken, pclk);
  sensor_t *s = esp_camera_sensor_get();
  int res = s->set_pll(s, bypass, mul, sys, root, pre, seld5, pclken, pclk);

  if (res) { send500(); return; }

  server.sendHeader("Access-Control-Allow-Origin""*");
  server.send(200, _TEXT_, "OK");

}


//  /resolution
void handleESP32WindowRes() {

  if (eXi) Serial.printf("\n HTTP Request: %s from: %s\n", server.uri().c_str(), server.client().remoteIP().toString().c_str());

  if (server.arg("sx") == "" || server.arg("sy") == "" || server.arg("ex") == "" || \
   server.arg("ey") == "" || server.arg("offx") == "" || server.arg("offy") == "" || \
   server.arg("tx") == "" || server.arg("ty") == "" || server.arg("ox") == "" || \
   server.arg("oy") == "" || server.arg("scale") == "" || server.arg("binning") == "" ) { send500(); return; }

  int startX = server.arg("sx").toInt();
  int startY = server.arg("sy").toInt();
  int endX = server.arg("ex").toInt();
  int endY = server.arg("ey").toInt();
  int offsetX = server.arg("offx").toInt();
  int offsetY = server.arg("offy").toInt();
  int totalX = server.arg("tx").toInt();
  int totalY = server.arg("ty").toInt();
  int outputX = server.arg("ox").toInt();
  int outputY = server.arg("oy").toInt();
  bool scale = server.arg("scale").toInt();
  bool binning = server.arg("binning").toInt();

  if (eXi) Serial.printf("Set Window: Start: %d %d, End: %d %d, Offset: %d %d, Total: %d %d, Output: %d %d, Scale: %u, Binning: %u\n", startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning);

  sensor_t *s = esp_camera_sensor_get();
  int res = s->set_res_raw(s, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning);
  if (res) { send500(); return; }

  server.sendHeader("Access-Control-Allow-Origin""*");
  server.send(200, _TEXT_, "OK");
}




#endif
// End remote functions.





// Your commands. You're welcome.
//
String getCommands() {

  static char commands[6000];
  char *c = commands;

  c += sprintf(c, " Commands:\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    s/m/h/d(int)  Set new snap interval time**, in seconds/minutes/hours/days, e.g. s10\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    list          SLOWLY list all image files, with dates and sizes, recalculate space, etc. (web: /list)\n");
  c += sprintf(c, "    ql            Quick list of all image files (no sizes or dates)  (web: /ql)\n");
  c += sprintf(c, "    qla           Quick list of all files (including non-image files)\n");
  c += sprintf(c, "    l             List all image files in whatever mode you are currently in. (web: /l)\n");
  c += sprintf(c, "    tq            Toggle Quick Listing mode\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "                  NOTE: The 'list' command is SLOW when there are a lot of images\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    !             Capture an image and reset the timer to NOW\n");
  c += sprintf(c, "    .             Capture an image and DO NOT reset the timer\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    record        Start recording MJPEG Stream (will overwrite any previous recording)\n");
  c += sprintf(c, "    stop          Stop recording MJPEG Stream\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    i[*]          Print out information. With no suffix: current snap interval time\n");
  c += sprintf(c, "                  [a/d/e/o/a/q/r/s/t/z/etc..] information about various current settings, e.g. 'it'\n");
  c += sprintf(c, "    prefs         Print out all current i* settings (also see: 'nvs' command, below)\n");
  c += sprintf(c, "    t/time        Print out the local time / current system time (RTC)\n");
  c += sprintf(c, "    time *        Set system time to *, which is a UNIX UTC time, e.g.: time 1679897227\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    c             Camera Sensor API: \n");
  c += sprintf(c, "                  Use 'c param=value' to set any sensor value **, e.g.\n");
  c += sprintf(c, "                  'c xclk=12' (to set clock frequency) or 'c framesize=5' (set streaming frame size)\n");
  c += sprintf(c, "    size (int)    Set Image Capture frame size. (2MP:0-13, 3MP:0-17, 5MP:0-21) **\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    backup        Save all camera sensor settings to the backup slot\n");
  c += sprintf(c, "    restore       Restore camera sensor settings from the backup slot\n");
  c += sprintf(c, "    backup *      Save all camera sensor settings to user-specified preset named *\n");
  c += sprintf(c, "    restore *     Restore camera sensor settings from user-specified preset named *\n");
  c += sprintf(c, "    presets       List all currently saved backup presets\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    b(int)        Set LED Flash Brightness (0-255) (LED will flash momentarily at the new brightness). **\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    free          Print out Free Space and images-remaining estimate\n");
  c += sprintf(c, "    eject / ej    Stop SD Card, so it can be ejected safely\n");
  c += sprintf(c, "    insert / sd   Re-Start SD Card after being ejected\n");
  c += sprintf(c, "    test          Run benchmarks on current SD Card (this takes a few seconds)\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    e             Toggle Extended Information in the console. **\n");
  c += sprintf(c, "    dd            Display details in web listings (date and size) **\n");
  c += sprintf(c, "    to            Toggle Overwrite Mode (for numeric sequences, not timestamped file names). **\n");
  c += sprintf(c, "    tt            Toggle Timestamp File Names (see above setting). **\n");
  c += sprintf(c, "    ta            Toggle Auto-Snap (aka. Time-Lapse Capture - la raison d'etre!). **\n");
  c += sprintf(c, "    ts            Toggle Auto-Sleep Mode. **\n");
  c += sprintf(c, "    td            Toggle Deep/Light Sleep. **\n");
  c += sprintf(c, "    sleep         Put TL-CAM to Sleep immediately\n");
  c += sprintf(c, "    tr            Toggle Remote Control Features. **\n");
  c += sprintf(c, "    web           Start WiFi Remote Control Features after sleeping/stopping\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    sp            Pause / Resume the Streaming SlideShow. (web: /sp)\n");
  c += sprintf(c, "    skip *        Skip * number of images in the Streaming SlideShow. (web: /skip?skip=10 OR /skip10\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    erase         Delete all the image files from the SD Card\n");
  c += sprintf(c, "    wipe[*-[#]]   Delete image files [starting at file * [ ending at file #]]. e.g.\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "                    wipe 89-     (wipe all image files from 89 onwards)\n");
  c += sprintf(c, "                    wipe 89-95   (wipe all image files from 89 to 95, inclusive)\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "                  NOTE: Wiping from index * deletes files in the (l) list order. NAMES ARE IGNORED\n");
  c += sprintf(c, "                        Non-Image files also ignored. You will be asked to confirm this command\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    delete *      Delete specific file named *     e.g.: delete test.html\n");
  c += sprintf(c, "    rename * #    Rename file * to #    e.g.: rename Console.html console\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    uptime        Print out ESP32 module uptime\n");
  c += sprintf(c, "    memory        Print out current memory information\n");
  c += sprintf(c, "    x / reboot    Cleanly (i.e. store time, settings, flush buffers, etc.) shutdown and reboot ESP32 device\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    sensor        Print out sensor settings (web: /sensor)\n");
  c += sprintf(c, "    sensorx       Print out extended sensor settings with main register info (web: /sensorx)\n");
  c += sprintf(c, "    nvs           Print out NVS-stored main settings (web: /nvs)\n");
  c += sprintf(c, "    nvsl          Reload and print out NVS-stored settings\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    reset         Reset all NVS-saved settings back to (hard-coded) defaults and reboot\n");
  c += sprintf(c, "    nvswipe       Completely Wipe Entire NVRAM and reboot. (you will be asked to confirm)\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    help          Print out this list of commands\n");
  c += sprintf(c, "\n");
  c += sprintf(c, "    NOTE: Settings marked ** are stored in NVS and recalled after a reboot\n");

  return commands;
}



// Print system uptime to the serial console in human-readable format.  TODO: wrap convertSeconds()
//
String getUptime() {

  char utBuffer[80] = {'\0'};

  uint64_t s = millis() / 1000; // from here we could use convertSeconds()
  uint32_t m = s / 60;
  uint16_t h = m / 60;
  uint16_t d = h / 24;

  String dpl = ""; if (d != 1) dpl = "s";
  String hpl = ""; if (h != 1) hpl = "s";
  String mpl = ""; if (m % 60 != 1) mpl = "s";
  String spl = ""; if (s % 60 != 1) spl = "s";

  sprintf(utBuffer, "\n Uptime: ");
  if (d > 0) sprintf(utBuffer + strlen(utBuffer), "%d day%s, ", d, dpl.c_str());
  if (d > 0 || h > 0) sprintf(utBuffer + strlen(utBuffer), "%d hour%s, ", h, hpl.c_str());
  if (d > 0 || h > 0 || m > 0) sprintf(utBuffer + strlen(utBuffer), "%d minute%s and ", m % 60, mpl.c_str());
  sprintf(utBuffer + strlen(utBuffer), "%llu second%s", s % 60, spl.c_str());

  return (String)utBuffer;
}


String convertSeconds(uint64_t totalSeconds) {

  String convertedTime;

  uint32_t minutes = totalSeconds / 60;
  uint32_t hours = minutes / 60;
  uint32_t days = hours / 24;

  totalSeconds %= 60;
  minutes %= 60;
  hours %= 24;

  if (days > 0) convertedTime += String(days) + "d ";
  if (hours > 0) convertedTime += String(hours) + "h ";
  if (minutes > 0) convertedTime += String(minutes) + "m ";
  convertedTime += (totalSeconds != 0)? String(totalSeconds) + "s" : "";

  return convertedTime;
}



// Better testing for empty chars..
bool isEmptyChar(const char **myChar) {
   if (*myChar && !*myChar[0]) {
    return true;
  }
  return false;
}


// Check if a user sensor backup of this name exists..
bool presetExists(String backupName) {
  String currentPresets = prefs.getString("presets""\n");
  if (currentPresets.indexOf("\n" + backupName) != -1) return true;
  return false;
}


// Pluck the parameter out of the command string..
// For command: foo=bar, we return "bar".
String trimParam(String commandString, int beginAt) {
  String trimmedParam = commandString.substring(0, beginAt);
  uint8_t startAt = beginAt;
  if (commandString.indexOf("=") != -1) startAt = commandString.indexOf("=") + 1;
  trimmedParam = commandString.substring(startAt);
  trimmedParam.trim();
  return trimmedParam;
}



/*

  Switch between NVS spaces.
                                 */

void prefsSwitch(String NVSKey = "TLCam") {
  prefs.end();
  prefs.begin(NVSKey.c_str());
}



String getFreeEntries() {
  char nvbuf[64];
  nvs_stats_t nvs_stats;
  nvs_get_stats(NULL, &nvs_stats);
  sprintf(nvbuf, "\n Used %d of %d total NVS entries (%d free)", nvs_stats.used_entries,
                                        nvs_stats.total_entries, nvs_stats.free_entries);
  return (String)nvbuf;
}




// Enter Sleep mode..

void goToSleep() {

  // Calculate sleep time (s)..  We sleep until just before snap time, minus time already passed since last snap

  uint16_t preWakeTime = 1000; // how long to wake up before snap time. One second for light sleep.
  if (deepSleep) preWakeTime = 3000; // Three seconds for Deep Sleep.
  uint64_t thisSleep = ( (delayTime - preWakeTime) / 1000 ) - ( (millis() - lastSnapTime) / 1000);

  esp_sleep_enable_timer_wakeup(thisSleep * SECOND_MICROS); // Convert to microseconds.
  uart_set_wakeup_threshold(UART_NUM_0, 3); // How many signal edges to wake up from light sleep (3, usually).
  esp_sleep_enable_uart_wakeup(UART_NUM_0); // Enable Serial wake-up.
  delay(100);

  if (eXi) Serial.println(" Going to sleep for " + convertSeconds(thisSleep) + " ...");
  Serial.flush();
#if defined ONLINE
  if (remControl) server.close();
  // This would happen anyway, but best to do it cleanly, so we can quickly wake everything back up again, if required.
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
#endif

  if (deepSleep) {
    esp_deep_sleep_start();
  } else {
    esp_light_sleep_start();
  }
}


// Wait for command confirmation..
bool waitForConfirmation(String confirmMsg, uint8_t timeout = 10) {

  uint64_t startWait = millis();

  LastMessage = confirmMsg + " (y/n)\n Option will timeout in " + (String)timeout + " seconds.";
  Serial.println(LastMessage);
  String command;

  while (true) {
#if defined ONLINE
    // Pick up y/n responses from web console..
    if (remControl) server.handleClient();
#endif

    if (Serial.peek() > 0 || xCommand != "") {
      if (xCommand == "") {
        command = Serial.readStringUntil('\n');
      } else {
        command = xCommand;
      }
      command.trim();
      command.toLowerCase();
      if (command == "y") return true;
      return false;
    }
    delay(10);
    if ( millis() > ( startWait + ( timeout * 1000 ) ) ) {
      Serial.println(" Response Time-Out.");
      return false;
    }
  }
  return false;
}



// Delete a file
String deleteFile(String file) {

  if ( !SD_MMC.exists("/" + file) ) {
    return "\n No such file as '" + file + "'";
  }

  String returnMsg, deletedOKAY = " FAIL";

  if ( SD_MMC.remove("/" + file ) ) {

    if (file.indexOf(fileEXT) != -1) {
      totalFileCount--;
      saveNumber--; // This will only work if you deleted the *last* image, but it's worth a try.
                  // makeFilename() will skip away to the next safe file name, regardless.
    }
    returnMsg = "\n " + file + " deleted";
    deletedOKAY = " OK";

    if ( SD_MMC.exists("/" + file) ) {
      deletedOKAY += "\n OH OH! FAIL! " + file + " STILL EXISTS!\n SD Error! Please reboot and try again.";
    }

  } else {
    returnMsg = "\n Failed to delete " + file;
  }

  return returnMsg + deletedOKAY;
}


// Rename a file..
String renameFile(String origin, String target) {

  if ( !SD_MMC.exists("/" + origin) ) {
    return " No such file as '" + origin + "'";
  }

  origin.trim();
  target.trim();
  String returnMsg, renamedOKAY;

  if (SD_MMC.rename("/" + origin, "/" + target)) {
    returnMsg = " renamed " + origin + " to " + target;
    renamedOKAY = " OK";
  } else {
    returnMsg = " FAILED to rename " + origin + " to " + target;
  }

  if ( SD_MMC.exists("/" + origin) ) {
    renamedOKAY += "\n OH OH! FAIL! " + origin + " STILL EXISTS!\n SD Error! Please reboot and try again.";
  }

  return returnMsg + renamedOKAY;
}





/*
  Construct a filename that looks something like "photo_000001.jpg" (with whatever fileNamePrefix)

  (there's no way you are fitting a million+ images on a 4GB SD Card)

  While it seems clever to start our new count at the existing file count (+1), there is the
  possibility that the user has deleted some images, perhaps removing images 2, 3 and 6 from a
  series of 10. And while our count of files now equals 7, it would be a mistake to start our write
  operations at file number 8, as that file already exists. Writing would be overwriting. Data would
  be lost <insert Wilhelm Scream>.

  So, regardless of the file "count", we test for the actual existence of a file with this name,
  and if it exists, we increase the counter by 1, and test again. This happens relatively quickly,
  so we will usually arrive at the correct starting filename (number) before we even take the first
  image.

  We can also create file names from the current date and time.
  See the dateTimeString preference for how to customise this output.

                                       */

String makeFilename(uint32_t startNum) {

  String filename;

  if (timeStampFileNames) {

    if (!getLocalTime(&timeinfo, 0) ) {
      Serial.println(" Failed to obtain time. Switching to numerical file names.");
      timeStampFileNames = false;
      return makeFilename(startNum);
    }

    char tBuffer[128] = {'\0'};
    // Pluck data from the struct using our user's predefined String..
    strftime(tBuffer, sizeof(tBuffer), dateTimeString, &timeinfo);
    filename += (String)tBuffer;

  } else {
    // Code like this gives me a fuzzy feeling.. while() loop? Nah! This is WAY more fun!
    if (startNum < 100000) filename += "0";
    if (startNum < 10000) filename += "0";
    if (startNum < 1000) filename += "0";
    if (startNum < 100) filename += "0";
    if (startNum < 10) filename += "0";

    filename += startNum;
  }

  filename.trim();
  if (filename == "") return "";

  // Put it all together..
  filename = "/" + fileNamePrefix + filename;

  // Looping around in numeric mode.
  if (!overWrite) {
    String testName = filename + fileEXT;
    const char *test = testName.c_str();
    // Test if this file exists, and if so, increase counter and try again.
    // I always get a kick out of recursive functions.
    if (SD_MMC.exists(test) ) return makeFilename(saveNumber++);
  }

  // We get here eventually..
  return filename;
}


/*
  Enter the void..
                    */

void loop() {

#if defined ONLINE
  if (remControl) server.handleClient();
#endif

  uint64_t currentTime = millis();
  String command, confirm;
  bool isSerial = false;

  // Read commands from the serial interface or action forced (web) commands..
  if (Serial.peek() > 0 || xCommand != "") {

    // NO overrides, must be a Serial command..
    if (xCommand == "") {
      command = Serial.readStringUntil('\n');
      isSerial = true;
    } else {
      // Override command..
      command = xCommand;
      xCommand = "";
#if defined ONLINE
      command = urlDecode(command);
#endif
    }


    // We have a command..
    if (command != "") {

      command.trim();

      // 1st character of the command..
      char cmd = command[0];

      // Everything after the 1st character..
      String value = command.substring(1);

      // TODO everything after the "="

      // value converted to an integer..
      uint64_t intValue = value.toInt();


      // Case-Sensitive Commands..

      // Delete a file..
      if (command.substring(0,6) == "delete" || command.substring(0,6) == "Delete") {
        value = command.substring(6);
        value.trim();
        LastMessage = deleteFile(value);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

      // Rename a file..
      if (command.substring(0,6) == "rename" || command.substring(0,6) == "Rename") {
        value = command.substring(6);
        value.trim();
        String origin = value.substring(0, value.indexOf(" "));
        String target = value.substring(value.indexOf(" ") + 1);
        LastMessage = renameFile(origin, target);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }


      // Switch everything to lower case..
      command.toLowerCase();
      if (eXi) Serial.printf(" command: %s\n", command.c_str()); //debug


      /*
          Case-Insensitive Commands..
                                        */



      // Gather up all "i*" settings information commands into one..
      if (command == "prefs") {
        LastMessage = "\n Current Settings: ";
        Serial.println(LastMessage);
      }

      // Print out memory info..
      if (command.substring(0,3) == "mem") {
        Serial.println();
        LastMessage = printMemoryInfo(true);
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Flip the extended info.. (and save state to NVS)
      if (command == "e") {
        eXi = eXi ? false : true;
        prefs.putBool("e", eXi);
        command = "ie";
      }
      if (command == "ie" || command == "prefs") {
        LastMessage = " Extended Info:  " +  (String)(eXi ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }

      // Toggle overWrite..
      if (command == "to") {
        overWrite = overWrite ? false : true;
        prefs.putBool("o", overWrite);
        command = "io";
      }
      if (command == "io" || command == "prefs") {
        LastMessage = " Overwrite Mode: " + (String)(overWrite ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }

      // Toggle auto-snap..
      if (command == "ta") {
        autoSnap = autoSnap ? false : true;
        prefs.putBool("a", autoSnap);
        command = "ia";
      }
      if (command == "ia" || command == "prefs") {
        LastMessage = " Auto-Snap: " + (String)(autoSnap ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }

      // Print out current snap interval..
      if (command == "i" || command == "prefs") {
        LastMessage = " Snap Interval: " + convertSeconds(delayTime/1000);
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }


      // Toggle Timestamp FileNames..
      if (command == "tt") {
        timeStampFileNames = timeStampFileNames ? false : true;
        prefs.putBool("t", timeStampFileNames);
        command = "it";
      }
      if (command == "it" || command == "prefs") {
        LastMessage = " Timestamp file names: " + (String)(timeStampFileNames ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }

#if defined ONLINE

      // Toggle Remote Control Features..
      if (command == "tr") {
        remControl = remControl ? false : true;
        prefs.putBool("r", remControl);
        if (remControl) {
          startOnlineFeatures();
        } else {
          server.close() ;
        }
        command = "ir";
      }
      if (command == "ir" || command == "prefs") {
        LastMessage = " Remote Control: " + (String)(remControl ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }


      // Restore web features after a sleep
      if (command == "web") {
        startOnlineFeatures();
        return;
      }

#endif

      // Print out a list of commands..
      if (command == "?" || command == "help") {
        LastMessage = getCommands();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Print out the sensor settings and registers..
      if (command == "sensorx") {
        getSensorStatus(true, true);
        return;
      }
      // Print out the sensor settings..
      if (command == "sensor") {
        getSensorStatus(true);
        return;
      }

      // Print out the stored NVS settings..
      if (command == "nvs") {
        LastMessage = gatherPrefs(false, false);
        LastMessage += gatherSensorPrefs();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Load and print out the stored NVS settings..
      if (command == "nvsl") {
        LastMessage = gatherPrefs(true, false);
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Print-out Uptime (49.7 days max) We could theoretically use esp_timer_get_time() (200+ years)

      // Oh wait! Hah!

      // When using realTime, we could store the /initial/ time we get from the *time server* and
      // ignore all this $h1t, instead calculate from /that/. Boom!
      // This looks like the coder part of me getting in the way of the programmer. 200 years? Pah!
      // (+ time *until* we get NTP update, of course).

      if (command == "ut" || command == "uptime") {
        LastMessage = getUptime();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // List all backup presets..
      if (command == "backups" || command == "presets") {
        // We switch to a "special" NVS space so that wiping settings doesn't wipe the presets list.
        prefsSwitch("presets");

        String presetsList = prefs.getString("index");
        presetsList.replace("\n""\n     ");

        LastMessage = "\n Current Presets:\n" + presetsList;
        prefsSwitch();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // Backup camera settings all-in-one to NVS (using esp camera built-in functions)
      if (command == "backup" ) {
        String backupName = "backup" + sensorType;
        LastMessage = " Configuration backup: " + saveSensorConfig(backupName.c_str());
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Backup to user preset..
      if (command.substring(0,6) == "backup") {

        String backupName = trimParam(command, 6);
        if (presetExists(backupName)) LastMessage = " Overwriting existing backup\n";

        LastMessage = " Saving sensor settings to preset " + backupName + ": " + saveSensorConfig(backupName.c_str());
        prefsSwitch("presets");
        String currentPresets = prefs.getString("index""\n");
        prefs.putString("index", currentPresets + "\n" + backupName);
        prefsSwitch();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;

      }

      // TODO clear backups (but not remove - it's not possible)


      // Restore from regular backup..
      if (command == "restore" ) {
        String backupName = "backup" + sensorType;
        LastMessage = " Restoring sensor settings: " + loadSensorConfig(backupName.c_str());
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // Restore specific user backup preset..
      if (command.substring(0,7) == "restore") {
        String backupName = trimParam(command, 7);
        if (presetExists(backupName)) {
          LastMessage = " Restoring sensor settings from preset '" + backupName + "': " + loadSensorConfig(backupName.c_str());
        } else {
          LastMessage = " No such preset as '" + backupName + "'.";
        }
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // Alternative (quicker) Capture Size setting method..
      if (command.substring(0,4) == "size") {
        uint16_t newSize = trimParam(command, 4).toInt();
        if ( newSize < 1 || newSize > maxSize ) newSize = captureSize;
        captureSize = newSize;
        prefs.putShort("size", captureSize);
        command = "iz";
      }

      if (command == "iz" || command == "prefs") {
        LastMessage = " Capture Size: " + frameSizes[captureSize];
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }


      /*
          Commands that require the SD Card to be inserted, or not..
                                                                      */


      // In case of user-error..
      String eejitMsg = "\n SD CARD IS EJECTED, IDIOT!\n"// I'm a sketch with attitude!
      eejitMsg += " First insert an SD Card and then use the 'sd' command to initialise.";


      // Wipe all images from SD Card..
      // As soon as this happens, I take another photo.
      // If you want your SD card empty, use a PC: I am a camera server. I exist to take pictures!

      if (command == "erase") {
        command = "wipe 1-";
      }

      // Wipe some or all images from the SD card.
      if (command.substring(0,4) == "wipe") {

        if (ejected) {
          LastMessage = eejitMsg;
        } else {

          uint64_t delIDX = 0, delIDZ = UINT64_MAX;
          String tmpVals = command.substring(4);

          // 'wipe 89-' wipes all files from 89 onwards.
          // 'wipe 89' does nothing at all.
          if (tmpVals.indexOf("-") != -1) {

            // Start deleting here..
            delIDX = tmpVals.substring(0, tmpVals.indexOf("-") ).toInt();

            // Check if an ending index was provided..
            String tmpZVal = tmpVals.substring(tmpVals.indexOf("-")+1);
            tmpZVal.trim();
            // If so, set the end of our range..
            if (tmpZVal != "") delIDZ =tmpVals.substring(tmpVals.indexOf("-")+1).toInt();

            String displayTop = (delIDZ == UINT64_MAX) ? "(last file)" : (String)delIDZ;

            confirm = "\n Are you absolutely sure you wish to delete files " + (String)delIDX + "-" + displayTop + "?";
            if (waitForConfirmation(confirm, 10)) {

              LastMessage = "\n Deleting Image Files..\n\n" + listDir(true, false, false, true, false, delIDX, delIDZ);
              mostRecentPic = (delIDX == 0) ? 0 : delIDX - 1;

            } else {
              LastMessage = " Delete Operations Aborted.";
            }

          } else {
            LastMessage = " Ambiguous Delete Command. Aborting..";
          }
        }
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // Remaining space?
      if (command == "free" || command == "space") {
        if (ejected) {
          LastMessage = eejitMsg;
        } else {
          LastMessage = printSpace();
        }
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // Eject SD Card safely.
      if (command == "eject" || command == "ej") {
        if (ejected) {
          LastMessage = eejitMsg;
        } else {
          SD_MMC.end();
          LastMessage = " SD Card Stopped.\n Your SD Card can now be safely removed.";
          ejected = true;
        }
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }

      // After Ejecting, you may want to put the card back in and resume right where you left off..
      if (command == "insert" || command == "sd") {
        if (!ejected) {
          LastMessage = " SD Card is not ejected!";
          if (isSerial || eXi) Serial.println(LastMessage);
        } else {
          ejected = false;
          if (startMicroSD()) {
            String stateA = "resumed";
            if (!autoSnap) stateA = "enabled (note: auto-snap is currently disabled)";

            LastMessage = " SD Card started successfully. Image acquisition " + stateA;
          } else {
            LastMessage = " SD Card Error. Please try again.";
            ejected = true;
          }
          if (isSerial || eXi) Serial.println("\n" + LastMessage);
        }
        return;
      }

      // Test SD Card speed..
      if (command == "test" || command == "benchmark") {
         if (ejected) {
          LastMessage = eejitMsg;
        } else {
          LastMessage = benchmarkSD();
        }
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }


      // Take a pic RIGHT NOW and reset the timer to NOW..
      if (command == "!") {
        if (ejected) {
          LastMessage = eejitMsg;
          if (isSerial || eXi) Serial.println(LastMessage);
        } else {
          LastMessage = "SNAP!";
          mostRecentPic = 0;
        }
        return;
      }

      // Take a pic RIGHT NOW and DO NOT reset the timer..
      // This is the default for the web interface.
      if (command == ".") {
        if (ejected) {
          LastMessage = eejitMsg;
        } else {
          LastMessage = "SNAP!";
          instantPic();
        }
        return;
      }


      // Manually Enter Sleep..
      if (command == "sleep") {
        goToSleep();
        return;
      }

      // Flip the auto-sleep flag..
      if (command == "ts" || command == "togglesleep") {
        doSleep = doSleep ? false : true;
        prefs.putBool("z", doSleep);
        command = "is";
      }
      if (command == "is" || command == "prefs") {
        LastMessage = " Auto-Sleep: " + (String)(doSleep ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }

      // Deep / Light Sleep..
      if (command == "td" || command == "toggledeep" ) {
        deepSleep = deepSleep ? false : true;
        prefs.putBool("s", deepSleep);
        command = "id";
      }
      if (command == "id" || command == "prefs") {
        LastMessage = " Sleep Mode: " + (String)(deepSleep ? "Deep Sleep" : "Light Sleep");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }



      /*
          Commands that reboot the device..
                                             */



      // Reset all prefs back to defaults..
      if (command == "reset") {
        confirm = "\n Are you ABSOLUTELY SURE you wish to WIPE your NVS-saved settings?";
        if (waitForConfirmation(confirm)) {
          LastMessage = "\n DESTRUCTION OF NVS SETTINGS COMMENCING..\n We will now reboot to hard-coded defaults.\n";
          LastMessage += "\n Backup sensor settings are unaffected.";
          prefs.clear();
          command = "x";
        } else {
          LastMessage = " Settings Destruction Sequence Aborted.";
          return;
        }
      }

      // Wipe all saved settings..
      if (command == "nvswipe") {
        confirm = "\n Are you ABSOLUTELY SURE you wish to COMPLETELY WIPE your NVS partition?";
        if (waitForConfirmation(confirm)) {
          LastMessage = "\n DESTRUCTION OF NVS PARTITION AND ALL SETTINGS COMMENCING..\n Enjoy your blank slate!";
          if (isSerial || eXi) Serial.println(LastMessage);
          WipeNVRAM();
          command = "x";
        } else {
          LastMessage = " NVS Destruction Sequence Aborted.";
          return;
        }
        if (isSerial || eXi) Serial.println(LastMessage);
      }


      // Reboot..
      if (command == "x" || command == "reboot") {
        storeUTC();
        LastMessage += "\n Rebooting..";
        if (isSerial || eXi) Serial.println(LastMessage);
#if defined ONLINE
        server.close();
#endif
        Serial.flush();
        SD_MMC.end();
        prefs.end();
        ESP.restart();
      }

      /*
          Finished with commands that reboot the device..
                                                           */




      // List all images on SD Card.. (this sentence works with or without the two "the"s! Gotta love English.)
      // Simple "l" gets whatever type of list is in operation. "list" forces a full list.
      if (command == "l" || command == "list") {
        if (ejected) {
          LastMessage = eejitMsg;
          if (isSerial || eXi) Serial.println(LastMessage);
          return;
        } else {
          bool quickListingsTMP = quickListings;
          if (command == "list") {
            quickListings = false;
          }
          LastMessage = listDir(true);
          if (isSerial || eXi) Serial.println(LastMessage);
          if (command == "list") {
            quickListings = quickListingsTMP;
            command = "free";
          }
        }
      }

      // Quick List..
      if (command == "ql") {
        LastMessage = "\n" + quickList(true);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

      // Quick List ALL files..
      if (command == "qla") {
        LastMessage = "\n" + quickList(true, true);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }


      // NOT Quick List (test)..
      if (command == "nq") {
        LastMessage = "\n Computing. Please wait...\n" + notQuickLst();
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

      // NOT Quick List (ALL FILES)..
      if (command == "nqa") {
        LastMessage = "\n Computing. Please wait...\n" + notQuickLst(true);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

      // Toggle Quick Listing Mode..
      if (command == "tq") {
        quickListings = quickListings ? false : true;
        prefs.putBool("q", quickListings);
        command = "iq";
      }

      if (command == "iq" || command == "prefs") {
        LastMessage = " Quick Listing Mode: " + (String)(quickListings ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        if (command != "prefs") return;
      }



      // Display Details (size and date info in listings)
      if (command == "dd") {
        displayDetails = displayDetails ? false : true;
        prefs.putBool("p", displayDetails);
        command = "ip";
      }

      if (command == "ip" || command == "prefs") {
        LastMessage = " Display Details (date & size): " + (String)(displayDetails ? "Enabled" : "Disabled");
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }


      // Skip images in the Streaming SlideShow..
      if (command.substring(0,4) == "skip") {
        if (command.indexOf("=") != -1) {
          LastMessage = " You do not need an '=' sign in this command. Simply do: skip50";
        } else {
#if defined ONLINE
          if (sShowPlaying) {
            sssSkip = command.substring(4).toInt();
            LastMessage = " Streaming SlideShow Skipping " + (String)sssSkip + " images";
          } else {
            LastMessage = " Streaming SlideShow is not playing!";
          }
#endif
        }
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

#if defined ONLINE
      // Pause / Resume the Streaming SlideShow
      if (command == "sp") {
        if (sShowPlaying) {
          sssPause = sssPause ? false : true;
          LastMessage = " Streaming SlideShow " + (String)(sssPause ? "Paused" : "Resumed");
        } else {
          LastMessage = " Streaming SlideShow is not playing!";
        }
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }

      if (command == "next") {
        if (sShowPlaying) {
          sssSkip = -1;
          LastMessage = " Streaming SlideShow Skipping to next image";
        } else {
          LastMessage = " Streaming SlideShow is not playing!";
        }
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }
#endif


      if (command == "record") {
        doRecording = true;
        simpleStreamRecord();
        return;
      }

      if (command == "stop") {
        doRecording = false;
        return;
      }


      // Test Command..
      if (command == "wtf") {
        // LastMessage = makeFilename(100);
        if (isSerial || eXi) Serial.println(LastMessage);
        return;
      }


      // Testing
      if (command == "tm") {

        time_t now;
        struct tm timeDetails;

        time(&now);
        localtime_r(&now, &timeDetails);
        Serial.print("The current date and time is ");
        Serial.println(&timeDetails, "%A, %B %d %Y %H:%M:%S");

      }

      // Print out the current Time..
      if (command == "t") {

        if (getLocalTime(&timeinfo)) {

          char buffer[128] = {'\0'};
          strftime(buffer, sizeof(buffer), " Time: %H:%M:%S, %A %B %d %Y", &timeinfo);

          LastMessage = (String)buffer;
          if (isSerial || eXi) Serial.println("\n" + LastMessage);
        } else {
          LastMessage = " Failed to parse time!";
          if (isSerial || eXi) Serial.println(LastMessage);
        }
        return;
      }


      // Print local time/date information..
      // there is a shorter way to do this!
      if (command == "time") {

        struct timeval tv;
        time_t t;
        struct tm *timeStruct;
        char buffer[256] = {'\0'};

        gettimeofday(&tv, NULL);
        t = tv.tv_sec;
        timeStruct = localtime(&t);
        strftime(buffer, sizeof(buffer), " Today is %A, %B %d. And what a wonderful day!\n"
                                         " The time is precisely %I:%M:%S %p.\n\n"
                                         " Local Time: %c\n", timeStruct);
        LastMessage = (String)buffer;
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
        return;
      }


      // Set time from UTC UNIX time (see: https://www.unixtimestamp.com )
      // TODO: user-friendly version
      // TODO: enable user to skip NTP altogether when no internet and use fall-back (or stored) time.

      if (command.substring(0,4) == "time") {

        value = command.substring(5);
        value.trim();

        if (setStoredTime(value.toInt())) {
          LastMessage = " Setting time and date..";
          if (isSerial || eXi) Serial.println("\n" + LastMessage);
        }

        xCommand = "t";
        return;
      }

      // char-based commands..

      // Set Image Parameters..
      if (cmd == 'c' ) {
        String cameraSetting = command.substring(1);
        String setVar = cameraSetting.substring(0, cameraSetting.indexOf("="));
        String setVal = cameraSetting.substring(cameraSetting.indexOf("=") + 1);
        setVar.trim(); setVal.trim();
        setSensorParameters(setVar, setVal);
        return;
      }


      // Set Snap Interval..
      if ( (cmd == 's' || cmd == 'm' || cmd == 'h' || cmd == 'd') && value != "") {

        String plural = "";
        if (intValue != 1) plural = "s";

        String type = "second";
        uint64_t multiplier = 1000;

        if (cmd == 'm') {
          multiplier = MINUTE_MILLIS;
          type = "minute";
        }

        if (cmd == 'h') {
          multiplier = HOUR_MILLIS;
          type = "hour";
        }

        if (cmd == 'd') {
          multiplier = DAY_MILLIS;
          type = "day";
        }

        if (intValue != 0 || value == "0") {
          LastMessage = " Snap Interval changed to " + (String)intValue + " " + type + plural;
          if (isSerial || eXi) Serial.println(LastMessage + "\n");
          delayTime = (intValue * multiplier);
          prefs.putULong64("d", delayTime);
        }
        return;

      }

      // Set the (potentially-blinding) brightness of the LED Flash..
      if (cmd == 'b' && value != "") {
        if (command.indexOf("=") != -1) {
          LastMessage = " You do not need an '=' sign in this command. Simply do: b2";
        } else {
          ledBrightness = intValue;
          LastMessage = " LED Brightness set to: " + (String)ledBrightness;
          prefs.putUChar("b", ledBrightness);
          flashLED(600) ;
        }
        if (isSerial || eXi) Serial.println(LastMessage);
      }


      if (command == "") {
        LastMessage = getCommands();
        if (isSerial || eXi) Serial.println("\n" + LastMessage);
      }


    }
  }


  // ABORT NOW! For some reason..
  if (ABORT != "0") {
    if (!abortUserNotified) {
      Serial.printf(" ABORT! Reason: %s\n", ABORT.c_str());
      abortUserNotified = true;
    }
    return;
  }


  // Go to deep sleep (by default, you have 10 seconds (deepSleepDelay) to send commands before we re-enter sleep)..
  // Don't bother entering Deep Sleep if delayTime is less than 20 seconds.
  if (doSleep && deepSleep && delayTime > 20000 && currentTime > (lastSnapTime + (deepSleepDelay * 1000)) ) goToSleep();


  // If SD Card is not ejected, let's roll..
  if (!ejected) {

    if ( (SD_MMC.totalBytes() - SD_MMC.usedBytes() - levellingBytes ) < _1MB_) {
      if (overWrite && !timeStampFileNames) {
        saveNumber = 0;
      } else {
        ABORT = " Out of Space!";
        return;
      }
    }

    if (autoSnap) {

      // Take first pic immediately..
      if (mostRecentPic < 1) lastSnapTime = currentTime - delayTime - 1000;

      // Time to snap?
      if (currentTime < (lastSnapTime + delayTime) ) return;

      // From here on, code only runs if a picture is due to be taken..
      lastSnapTime = currentTime;

      // Keep a count of the number of photos we have taken.
      // The count initially begins from the current number of image files on the SD Card.
      saveNumber++;
      mostRecentPic = saveNumber; // /mostly/ used as a boolean
      // We set this even if no pic is taken (e.g. user messed up date string) to prevent a nasty starting loop.

      // Get a safe filename for this image.. (or not, if overWrite == true)
      String filename = makeFilename(saveNumber);

      // SNAP!
      if (filename != "") {
        if (!takePhoto(filename)) {
          Serial.println(" Problem capturing image! Please investigate.");
        }
      } else {
        Serial.println(" Could not create a valid file name! Please investigate.");
      }
    }
  }

  // A signal in my lizard-coding-brain tells me I need to do this..
  delay(1);

  // Go to sleep..
  if (doSleep && !deepSleep && delayTime > 3000)  goToSleep();

}



void setup() {

  Serial.begin(115200);

  Serial.println("\n Welcome to TL-CAM!");

  /*
  Non-Volatile Storage
  i.e. settings which are remembered between reboots, even after sketch uploads. */

  Serial.println("\n Retrieving preferences from NVRAM..\n");

  // Fire up NVS for preferences storage..
  esp_err_t err = nvs_flash_init();
  if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) WipeNVRAM();
  ESP_ERROR_CHECK(err);

  // Everything except backups goes in here..
  prefs.begin("TLCam");

  if (prefs.getULong64("d") != 0) {
    // Grab stored (milliseconds) time..
    delayTime = prefs.getULong64("d");
  } else {
    // Set hard-coded snap delay to milliseconds..
    if (delaySeconds != 0) {
      delayTime = delaySeconds * 1000;
    } else {
      delayTime = delayMinutes * MINUTE_MILLIS;
    }
  }

  // Load up settings from NVS..
  String prefsString = gatherPrefs(true);
  if (eXi) Serial.print(prefsString);

  // Let's go..
  if (!startMicroSD()) {
    ejected = true;
    Serial.println(" SD INIT Failed.\n Insert an SD card and issue an 'sd' command to continue.");
  }
  if (!startCamera()) ABORT = " Camera INIT Failed.\n Image acquisition will not be possible.";

#if defined ONLINE
    startOnlineFeatures();
#endif

  // Set estimated average size based on current capture size.
  averageSize = avgSizes[captureSize];

  // Print out a list of the available commands to the Serial console..
  Serial.println("\n Send 'help' or '?' for a list of available commands.\n" );
  Serial.flush();

   // Setup LED Flash PWM..
  ledcSetup(ledChannel, ledFreq, ledResolution);
  ledcAttachPin(FLASH_PIN, ledChannel);
}

/*

CHANGES:

  1.0.0.0

    First Public Release.


  1.0.1.0

    Fixed the compilation errors that you got if you disabled ONLINE features.

  1.0.2.0

    Improved sensor settings reporting.


*/

Welcome to autoconfig.corz.org!

I'm always messing around with the back-end.. See a bug? Wait a minute and try again. Still see a bug? Mail Me!