Portfolio: Silent Countdown Timer

By

View the app: Silent Countdown Timer

About the App

I needed a countdown timer without an audible alarm and that would keep counting into negative numbers when time expired. This is especially useful for helping people who are speaking in public keep track of time. I couldn't find such a timer, so I wrote one. Configuration can be saved in multiple ways, or specified via the URL to facilitate insertion into existing program flows.

Source Files

timer.js

timer = {};
schedule = [];
query_string = new URLSearchParams(window.location.search);

$(window).ready(function (){
  timer.merge({
    time: false,
    initial_time: false,
    interval_id: false,
    threshold1: false,
    threshold2: false,
    expired_interval_id: false,
    fullscreen: false,
    resize_timer: false,
    expired_font_size_timer: false,
    count_to_time: false,
    timer_type: 'single',
    timer_index: false,
    no_color: false,
  });
  if (localStorage.timer) {
    timer.merge(JSON.parse(localStorage.timer));
  }
  if (localStorage.schedule) {
    schedule = JSON.parse(localStorage.schedule);
  }
  if (timer.saved_time) {
    if (timer.timer_type === 'single') {
      var type = (timer.count_to_time) ? 'time' : 'duration';
      insert_into_single_form({type:type, time:timer.saved_time});
      update_screen(timer.saved_time, true);
    } else {
      setTimeout(function () {
        var i, hms;
        $('#schedule').val('group');
        $('#group').removeClass('hide');
        $('#single').addClass('hide');
        for (i=0; i<schedule.length; i++) {
          hms = s2hms(schedule[i].time);
          group_add_to_table(i, schedule[i].type, hms.h, hms.m, hms.s);
        }
        if (!timer.timer_index) timer.timer_index = 0;
        insert_into_single_form(schedule[timer.timer_index]);
        update_screen(schedule[timer.timer_index].time, true);
        show_nav_buttons();
      }, 200);
    }
  }
  if (query_string.has('fullscreen')) {
    timer.fullscreen = query_string.get('fullscreen') === 'true' ? true : false;
    if (timer.fullscreen && !is_fullscreen()) {
      timer.fullscreen = false; // set to false because toggle_fullscreen
                                // expects this variable to contain the
                                // current state, not the desired state.
      toggle_fullscreen();
      window.scrollTo(0, 0);
      setTimeout(function () {
        snackbar('no-auto-fullscreen', 10000);
      }, 1000);
    }
  }
  if (query_string.has('count_to_time')) {
    timer.count_to_time = query_string.get('count_to_time') === 'true' ? true : false;
  }
  if (query_string.has('no_color')) {
    timer.no_color = query_string.get('no_color') === 'true' ? true : false;
  }
  if (query_string.has('time')) {
    let time = query_string.get('time');
    time = time.split(':');
    if (time.length == 2) time = hms2s(0, time[0], time[1]);
    else time = hms2s(time[0], time[1], time[2]);
    timer.time = time;
    timer.initial_time = time;
    let type = timer.count_to_time ? 'time' : 'duration';
    insert_into_single_form({type:type, time:time});
    update_screen(time, true);
    set_thresholds();
    start_timer();
  }

  $('#schedule').on('change', function (){
    var type = $('#schedule').val();
    timer.timer_type = type;
    if (type == 'group') {
      $('#group').removeClass('hide');
      $('#single').addClass('hide');
      $('#fullscreen').addClass('hide');
      if (is_fullscreen()) toggle_fullscreen();
    } else {
      $('#group').addClass('hide');
      $('#single').removeClass('hide');
    }
  });
  document.onkeypress = keypressKeys;
  document.onkeyup = keyupKeys;
  $('#timer-display').on('mousemove touchstart', function () {
    console.debug('mousemove or touchstart event fired');
    show_controls();
  });
});

$(window).on('resize', function (){
  if (timer.fullscreen) {
    if (timer.resize_timer !== false) {
      clearTimeout(timer.resize_timer);
      timer.resize_timer = false;
    }
    timer.resize_timer = setTimeout(function() {
      if (timer.fullscreen) {
        size_font();
      }
      timer.resize_timer = false;
    }, 300);
  }
});

function tick() {
  timer.time -= 1;
  update_screen();
  check_thresholds();
}

function toggle_timer() {
  if(timer.interval_id === false) {
    start_timer();
  } else {
    pause_timer();
  }
  show_controls();
}

function set_thresholds() {
  if(timer.time >= 60*60) {        // 60 minutes
    timer.threshold1 = 10*60;      // 10 minutes
    timer.threshold2 = 5*60;       // 5 minutes
  } else if(timer.time >= 10*60) { // etc.
    timer.threshold1 = 5*60;
    timer.threshold2 = 2.5*60;
  } else if (timer.time >= 5*60) {
    timer.threshold1 = 60;
    timer.threshold2 = 30;
  } else if (timer.time >= 60) {
    timer.threshold1 = 15;
    timer.threshold2 = 5;
  } else if (timer.time >= 30) {
    timer.threshold1 = 5;
    timer.threshold2 = 2;
  } else {
    timer.threshold1 = false;
    timer.threshold2 = false;
  }
}

function start_timer() {
  var larger_time = false;
  if (read_timer_form() === 0) return alert('You need to first set the time before starting the timer!');
  $('#screen').removeClass('expired', 'expired-alternate');
  $('#stop-button').addClass('hide');
  $('#edit-button').addClass('hide');
  $('#pause-button').removeClass('hide');
  $('#play-button').addClass('hide');
  hide_nav_buttons();
  if (timer.time === false || timer.count_to_time) {
    var mode = $('input[name=mode]:checked').val();
    if (mode == "time") {
      var target = read_timer_form();
      var now = new Date(Date.now());
      now = hms2s(now.getHours(), now.getMinutes(), now.getSeconds());
      if (target <= now) {
        target += 24*60*60;
        larger_time = true;
      }
      timer.time = target - now;
      timer.count_to_time = true;
    } else {
      timer.time = read_timer_form();
      timer.count_to_time = false;
    }
    timer.initial_time = timer.time;
    set_thresholds();
  }
  init_timer_display();
  timer.interval_id = setInterval(tick, 1000);
  if (timer.count_to_time && timer.fullscreen && larger_time) {
    size_font();
  }
}

function check_thresholds() {
  if (timer.no_color) return true;
  if (timer.expired_interval_id === false) {
    if (timer.time <= 0) {
      $('#screen').removeClass('threshold1').removeClass('threshold2');
      setTimeout(function () {
        timer.expired_interval_id = setInterval(display_expired, 250);
      }, 250);
    } else if (timer.threshold2 !== false && timer.time <= timer.threshold2) {
      $('#screen').removeClass('threshold1').addClass('threshold2');
    } else if (timer.threshold1 !== false && timer.time <= timer.threshold1) {
      $('#screen').removeClass('threshold2').addClass('threshold1');
    }
  }
}

function pause_timer() {
  if (timer.interval_id !== false) {
    clearInterval(timer.interval_id);
    timer.interval_id = false;
  }
  if (timer.expired_interval_id !== false) {
    clearInterval(timer.expired_interval_id);
    timer.expired_interval_id = false;
  }
  if (timer.expired_font_size_timer !== false) {
    clearInterval(timer.expired_font_size_timer);
    timer.expired_font_size_timer = false;
  }
  $('#pause-button').addClass('hide');
  $('#stop-button').removeClass('hide');
  $('#play-button').removeClass('hide');
  $('#edit-button').removeClass('hide');
  if(timer.timer_type == 'group') {
    show_nav_buttons();
  }
}

function stop_timer() {
  pause_timer();
  reset_timer();
  $('#stop-button').addClass('hide');
}

function reset_timer() {
  pause_timer();
  var h = $('#input-h')[0].value * 1;
  var m = $('#input-m')[0].value * 1;
  var s = $('#input-s')[0].value * 1;
  if (timer.initial_time === false) {
    timer.time = false;
    timer.initial_time = false;
    timer.threshold1 = false;
    timer.threshold2 = false;
    timer.count_to_time = false;
    $('#input-h')[0].value = '';
    $('#input-m')[0].value = '';
    $('#input-s')[0].value = '';
  } else if ((h > 0 || m > 0 || s > 0) && read_timer_form() != timer.initial_time) {
    timer.time = read_timer_form();
    timer.initial_time = timer.time;
  } else {
    timer.time = timer.initial_time;
  }
  $('#screen').removeClass('threshold2 threshold1 expired expired-alternate');
  update_screen(undefined, true);
  if ($('#timer-display').hasClass('large')) {
    size_font();
  }
}

function edit() {
  if ($('#numbers').hasClass('hide')) {
    //leaving edit mode
    if($('#schedule').val() == 'group') {
      group_add_new(true);
      if(schedule.length == 0) {
        alert('Please fill in all the necessary information.');
        return false;
      }
      timer.timer_type = 'group';
      if(timer.timer_index === false) timer.timer_index = 0;
      insert_into_single_form(schedule[timer.timer_index]);
      timer.saved_time = read_timer_form();
      show_nav_buttons();

    } else {
      timer.timer_type = 'single';
      timer.timer_index = false;
      timer.saved_time = read_timer_form();
      timer.count_to_time = ($('input[name=mode]:checked').val() === 'time') ? true : false;
    }
    $('#timer-setup').addClass('hide');
    $('#numbers').removeClass('hide');
    $('#edit-button')[0].innerHTML = 'edit';
    $('#save-button').addClass('hide');
    $('#delete-button').addClass('hide');
    $('#play-button').removeClass('hide');
    $('#fullscreen').removeClass('hide');
    update_screen(read_timer_form(), true);
    show_controls();
    timer.time = false;
  } else {
    //entering edit mode
    if($('#schedule').val() == 'group' && is_fullscreen()) {
      toggle_fullscreen();
    }
    reset_timer();
    $('#timer-setup').removeClass('hide');
    $('#numbers').addClass('hide');
    $('#edit-button')[0].innerHTML = 'done';
    $('#save-button').removeClass('hide');
    if(localStorage.timer) $('#delete-button').removeClass('hide');
    $('#play-button').addClass('hide');
    $('#pause-button').addClass('hide');
    $('#stop-button').addClass('hide');
    hide_nav_buttons();
  }
  if (timer.fullscreen) size_font();
  return true;
}

function save() {
  if(confirm('Press OK to save your current settings between sessions, or cancel to, well, cancel.')) {
    if(edit()) {
      var t = JSON.stringify(timer);
      t = JSON.parse(t);
      delete t.interval_id;
      delete t.threshold1;
      delete t.threshold2;
      delete t.expired_interval_id;
      delete t.fullscreen;
      delete t.resize_timer;
      delete t.expired_font_size_timer;
      delete t.time;
      delete t.hide_intv;
      delete t.no_color;
      delete t.count_to_time;
      delete t.resize_timer;
      localStorage.timer = JSON.stringify(t);
      localStorage.schedule = JSON.stringify(schedule);
    }
  }
}

function delete_settings() {
  if (confirm('Press OK to delete your saved settings, or press cancel if you want to keep your saved settings.')) {
    delete localStorage.timer;
    delete localStorage.schedule;
    $('#delete-button').addClass('hide');
    alert('Your saved settings have been deleted.');
  }
}

function read_timer_form() {
  var h = $('#input-h')[0].value * 60 * 60;
  var m = $('#input-m')[0].value * 60;
  var s = $('#input-s')[0].value * 1;
  return h + m + s;
}

function toggle_fullscreen() {
  var selectors_to_hide = ['.site-footer', 'header', 'ins', '#title', '#postinfo', '#content > h2', '#content > h3', '#content > h4', '#content > p', '#content > ul', 'iframe', '#help-text'];
  toggle_hidden_selectors(selectors_to_hide);
  if (timer.fullscreen) {
    //exit fullscreen
    $('#timer-display').removeClass('large');
    $('#content').removeClass('fullscreen');
    $('#fullscreen')[0].innerHTML = 'fullscreen';
    $('#screen').css({fontSize: 'inherit'});
    $('#which-mode input').css({height: 'auto'});
    if (is_fullscreen()) {
      exit_fullscreen();
      show_controls();
    }
  } else {
    //enter fullscreen
    $('#timer-display').addClass('large');
    $('#content').addClass('fullscreen');
    $('#fullscreen')[0].innerHTML = 'fullscreen_exit';
    for (var i=0; i<10; i++) {
      if (is_fullscreen()) {
        break;
      } else {
        enter_fullscreen();
        setTimeout(hide_controls, 8000);
      }
    }
    size_font();
  }
  timer.fullscreen = !timer.fullscreen;
}

function show_controls() {
  var hide_timeout = 3000;
  $('#controls').css('opacity', 1);
  if($('#timer-display').hasClass('large')) {
    if(timer.hide_intv) {
      clearTimeout(timer.hide_intv);
      timer.hide_intv = null;
    }
    timer.hide_intv = setTimeout(function() {
      timer.hide_intv = null;
      hide_controls();
    }, hide_timeout);
  }
}

function hide_controls() {
  if($('#timer-display').hasClass('large') && $('#timer-setup').hasClass('hide')) {
    $('#controls').css('opacity', 0);
  }
}

function display_expired() {
  if (timer.no_color) return true;
  $('#screen').toggleClass('expired-alternate');
}

function init_timer_display() {
  update_screen();
}

function size_font() {
  //function yoinked and modified from https://stackoverflow.com/a/7873401/713735
  var content = $('#screen');
  var radio_controls = $('#which-mode input');
  content.css('overflow',"auto"); //for Firefox, but won't ruin for other browsers
  var size = 32;
  var changes = 0;
  var success = true;
  var max_changes = 500;
  content.css({fontSize: size + "px"});
  radio_controls.css({height: size + "px"});
  while ((content[0].scrollWidth <= document.documentElement.clientWidth) && (content[0].scrollHeight <= document.documentElement.clientHeight)) {
    content.css({fontSize: size + "px"});
    radio_controls.css({height: size + "px"});
    size += 3;
    changes++;
    if (changes > max_changes) {
      //failsafe..
      console.warn('size_font: Too many changes: ' + changes +
                   ' (max: ' + max_changes + '). Undoing...');
      success = false;
      break;
    }
  }
  if (changes > 0) {
    //upon failure, revert to original font size:
    if (success)
      size -= 7;
    else
      size -= changes * 3;
    content.css({fontSize: size + "px"});
    radio_controls.css({height: size + "px"});
  }
}

function update_screen(value, update_from_edit) {
  var s = timer.time, h = 0, m = 0, neg = false;
  if (value) s = value;
  if (s === false) s = 0;
  var hms = s2hms(s);
  h = hms.h;
  m = hms.m;
  s = hms.s;
  $('#h')[0].innerHTML = h;
  $('#m')[0].innerHTML = m;
  $('#s')[0].innerHTML = s;
  if (timer.time <= 0 && timer.expired_interval_id === false && !update_from_edit) {
    var font_sized_after_expire = false;
    if (!timer.no_color) {
      $('#screen').addClass('expired');
    }
    if($('#timer-display').hasClass('large')) {
      timer.expired_font_size_timer = setInterval(function () {
        if($('#timer-display').hasClass('large')) {
          if(font_sized_after_expire) {
            if(((timer.time*-1) + 1) % (60*60) === 0) {
              size_font();
            }
          } else {
            size_font();
            font_sized_after_expire = true;
          }
        }
      }, 1000);
    }
    //timer.expired_interval_id = setInterval(display_expired, 250);
  } else if (timer.time > 1 && (timer.time + 1) % (60*60) === 0 && $('#timer-display').hasClass('large')) {
    size_font();
  }
}

// Yoinked and modified from https://stackoverflow.com/a/10627148/713735
function is_fullscreen() {
  return !((document.fullScreenElement && document.fullScreenElement !== null) || (!document.mozFullScreen && !document.webkitIsFullScreen));
}

function enter_fullscreen() {
  var elem = $('#timer-display')[0];
  if (elem.requestFullScreen) {
    elem.requestFullScreen();
  } else if (elem.mozRequestFullScreen) {
    elem.mozRequestFullScreen();
  } else if (elem.webkitRequestFullScreen) {
    elem.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT);
  }
}

function exit_fullscreen() {
  if (document.cancelFullScreen) {
    document.cancelFullScreen();
  } else if (document.mozCancelFullScreen) {
    document.mozCancelFullScreen();
  } else if (document.webkitCancelFullScreen) {
    document.webkitCancelFullScreen();
  }
}

function pad(n) {
  n += '';
  if (n.length > 1) return n;
  return '0' + n;
}

function pad_field(input) {
  input.value = pad(input.value);
}

function strip_leading_zeros(n) {
  if (n === '') n = 0;
  return parseInt(n);
}

function hms2s(h,m,s) {
  return strip_leading_zeros(h)*60*60 + strip_leading_zeros(m)*60 + strip_leading_zeros(s);
}

function s2hms(s) {
  var neg = false, m = 0, h = 0;
  if (s < 0) {
    neg = true;
    s *= -1;
  }
  if (s >= 60) {
    m = Math.floor(s / 60);
    s = s % 60;
  }
  if (m >= 60) {
    h = Math.floor(m / 60);
    m = m % 60;
  }
  if (neg) {
    if (h == 0) h = '-0';
    else h *= -1;
  }
  m = pad(m);
  s = pad(s);
  return {
    h: h,
    m: m,
    s: s
  };
}

function toggle_hidden_selectors(id_arr) {
  for (var i=0; i<id_arr.length; i++) {
    $(id_arr[i]).toggleClass('hide');
  }
}

function crEl(el) {
  return document.createElement(el);
}

function group_add_new(automated) {
  var type = $('#new-type');
  var tv = type.val();
  var h = $('#new-h');
  var hv = strip_leading_zeros(h.val());
  var m = $('#new-m');
  var mv = strip_leading_zeros(m.val());
  var s = $('#new-s');
  var sv = strip_leading_zeros(s.val());
  if ((tv !== 'duration' && tv !== 'time') || (!hv && !mv && !sv)) {
    if(!automated) alert('Please enter a value.');
    return false;
  }
  schedule.push({
    type: tv,
    time: hms2s(hv, mv, sv)
  });
  group_add_to_table(schedule.length-1, tv, hv, mv, sv);
  type.val('none');
  var objs = [h,m,s];
  for (var i=0; i<objs.length; i++) {
    objs[i].val('');
  }
}

function group_add_to_table(index, type, h, m, s) {
  if (!h) h = '0';
  if (!m) m = '00';
  if (!s) s = '00';
  var tr = crEl('TR');
  tr.id = 'group-' + index;
  var idx = crEl('TD');
  idx.id = 'group-index-' + index;
  idx.innerHTML = index;
  var tpe = crEl('TD');
  tpe.id = 'group-type-' + index;
  if (type == 'duration') tpe.innerHTML = 'Count for duration';
  else tpe.innerHTML = 'Count to time';
  var time = crEl('TD');
  time.id = 'group-time-' + index;
  time.className = 'one-line';
  time.innerHTML = h + ':' + pad(m) + ':' + pad(s);
  var actions = crEl('TD');
  actions.id = 'group-actions-' + index;
  actions.innerHTML = generate_group_action_buttons(index);
  tr.appendChild(idx);
  tr.appendChild(tpe);
  tr.appendChild(time);
  tr.appendChild(actions);
  document.getElementById('group-list').appendChild(tr);
}

function generate_group_action_buttons(index) {
  return '<i class="material-icons" id="group-edit-'+index+'" onclick="group_item_edit('+index+')" title="Edit">edit</i><i class="material-icons hide" id="group-save-'+index+'" onclick="group_item_save('+index+')" title="Save">save</i><i class="material-icons" id="group-delete-'+index+'" onclick="group_item_delete('+index+')" title="Delete">delete</i>';
}

function group_item_edit(index) {
  var type = $('#group-type-' + index)[0];
  var time = $('#group-time-' + index)[0];
  var select = '<select name="type"><option value="duration"';
  if (schedule[index].type === 'duration') select += ' selected';
  select += '>Set duration</option><option value="time"';
  if (schedule[index].type === 'time') select += ' selected';
  select += '>Set 24-hour time</option></select>';
  type.innerHTML = select;
  var hms = s2hms(schedule[index].time);
  time.innerHTML = '<input type="number" placeholder="H" min="0" id="new-h-'+index+'" value="'+hms.h+'"> : <input type="number" placeholder="M" min="0" max="59" id="new-m-'+index+'" onchange="pad_field(this)" value="'+hms.m+'"> : <input type="number" placeholder="S" min="0" max="59" id="new-s-'+index+'" onchange="pad_field(this)" value="'+hms.s+'">';
  $('#group-edit-'+index).addClass('hide');
  $('#group-save-'+index).removeClass('hide');
}

function group_item_save(index) {
  var type_cell = $('#group-type-' + index)[0];
  var time_cell = $('#group-time-' + index)[0];
  var type_data = $('#group-type-' + index + ' select').val();
  var time_data_h = strip_leading_zeros($('#new-h-' + index).val());
  time_data_h = (time_data_h) ? time_data_h : 0;
  var time_data_m = strip_leading_zeros($('#new-m-' + index).val());
  var time_data_s = strip_leading_zeros($('#new-s-' + index).val());
  schedule[index] = {
    type: type_data,
    time: hms2s(time_data_h, time_data_m, time_data_s)
  };
  type_cell.innerHTML = (type_data === 'duration') ? 'Count for duration' : 'Count to time';
  time_cell.innerHTML = time_data_h + ':' + pad(time_data_m) + ':' + pad(time_data_s);
  $('#group-edit-'+index).removeClass('hide');
  $('#group-save-'+index).addClass('hide');
}

function group_item_delete(index) {
  var cell;
  if (schedule.length == 1) {
    alert('Unable to delete the only timer. Add another timer or edit this one.');
    return false;
  }
  $('#group-' + index).remove();
  schedule.splice(index, 1);
  if (timer.timer_index >= index) timer.timer_index -= 1;
  for(var i=index+1; i<schedule.length+1; i++) {
    $('#group-' + i).attr('id', "group-" + (i-1));
    cell = $('#group-index-' + i);
    cell.attr('id', 'group-index-' + (i-1));
    cell[0].innerHTML = i-1;
    $('#group-type-' + i).attr('id', 'group-type-' + (i-1));
    $('#group-time-' + i).attr('id', 'group-time-' + (i-1));
    cell = $('#group-actions-' + i);
    cell.attr('id', 'group-actions-' + (i-1));
    cell[0].innerHTML = generate_group_action_buttons(i-1);
  }
}

function insert_into_single_form(data) {
  if (data.type == 'duration') $('#mode-countdown').trigger('click');
  else $('#mode-time').trigger('click');
  var hms = s2hms(data.time);
  $('#input-h').val(hms.h);
  $('#input-m').val(hms.m);
  $('#input-s').val(hms.s);
}

function group_prev() {
  if (timer.timer_type == 'group' && timer.timer_index > 0) {
    group_navigate(-1);
  }
}

function group_next() {
  if (timer.timer_type == 'group' && timer.timer_index < schedule.length-1) {
    group_navigate(1);
  }
}

function group_navigate(factor) {
  if (!factor) factor = 1;
  timer.timer_index += factor;
  insert_into_single_form(schedule[timer.timer_index]);
  timer.initial_time = schedule[timer.timer_index].time;
  reset_timer();
  if (schedule[timer.timer_index].type === 'duration') timer.count_to_time = false;
  else timer.count_to_time = true;
  update_screen(schedule[timer.timer_index].time);
  timer.time = false;
  start_timer();
  reset_timer();
  stop_timer();
}

function hide_nav_buttons() {
  $('#schedule-prev-button').addClass('hide');
  $('#schedule-next-button').addClass('hide');
}

function show_nav_buttons() {
  $('#schedule-prev-button').removeClass('hide');
  $('#schedule-next-button').removeClass('hide');
  if(timer.timer_index === 0) $('#schedule-prev-button').addClass('hide');
  if(schedule.length <= 1 || timer.timer_index >= schedule.length-1) $('#schedule-next-button').addClass('hide');
}

Object.prototype.merge = function(other) {
  if (Object.assign) {
    Object.assign(this, other);
  } else {
    for (var attrname in other) {
      this[attrname] = other[attrname];
    }
  }
}

// onkeypress fires when characters are typed. Other keys such as arrows and
// backspace don't fire this event.
function keypressKeys(evt) {
  if(this.activeElement.id == 'q') return true; //ignore input to the search box
  if(!evt) evt = window.event;
  // find the code of the pressed key.
  if(evt.altKey || evt.ctrlKey) return true; // We don't want Ctrl- or Alt-modified keys
  var code;
  if(evt.keyCode) code = evt.keyCode;
  else if(evt.charCode) code = evt.charCode;
  else code = 0;
  var key = String.fromCharCode(code);
  if(console) console.log('keypressKeys(): Key code: ' + code + '; Key: ' + key);

  // Decide which button to press
  if(key.match(/^[ ]$/)) toggle_timer();
  else if(key.match(/^[eE]$/)) edit();
  else if(key.match(/^[fF]$/)) toggle_fullscreen();
  else {
    if(console) console.info('keypressKeys(): No keybinding for "' + key + '" (code: ' + code + ')');
    return true; // The key wasn't meant for us. Perhaps someone else is interested.
  }
  return false; // We've processed the key. No one else need mess with it.
}

function keyupKeys (evt) {
  if(this.activeElement.id == 'q') return true; //ignore input to the search box
  if(!evt) evt = window.event;
  var code = evt.keyCode;
  if(console) console.log('keyupKeys(): Key code: ' + code);

  //handle special keys:
  switch(code) {
    case 39: // right arrow
      group_next(); break;
    case 37: // left arrow
      group_prev(); break;
    default: // Not a key this function handles
      if(console) console.log('keyupKeys(): Not handled');
      return true;
  }
  if(console) console.log('keyupKeys(): Handled');
  return false;
}

//TODO: refactor code, add save clear, address bug preventing deletion of saved items in Firefox

timer.html

This is an excerpt showing the HTML underlying the timer.

<div id="timer-display"><div class="cell">
  <div id="screen">
    <div id="numbers">
      <span id="h">0</span>:<span id="m">00</span>:<span id="s">00</span>
    </div>
    <div id="timer-setup" class="hide">
      <form method="post" onsubmit="return false;" action="javascript:edit()" id="set-time">
        <div>
          <div id="schedule-wrapper">
            <label for="schedule" style="cursor: default">Timer type:</label>
            <select id="schedule" name="schedule">
              <option value="single" selected>Single timer</option>
              <option value="group">Timer group</option>
            </select>
          </div>
          <div id="single">
            <div id="which-mode">Which mode do you want?
              <table>
                <tr>
                  <td><input type="radio" name="mode" id="mode-countdown" checked value="countdown"></td>
                  <td><label for="mode-countdown">Set the desired duration.</label></td>
                </tr>
                <tr>
                  <td><input type="radio" name="mode" id="mode-time" value="time"></td>
                  <td><label for="mode-time">Count down to the specified 24-hour time.</label></td>
                </tr>
              </table>
            </div>
            <div id="single-input-container" class="one-line">
              <input type="number" placeholder="H" min="0" id="input-h"> :
              <input type="number" placeholder="M" min="0" max="59" id="input-m" onchange="pad_field(this)"> :
              <input type="number" placeholder="S" min="0" max="59" id="input-s" onchange="pad_field(this)">
            </div>
          </div>
          <div id="group" class="hide">
            <table>
              <thead><tr>
                <th>&nbsp;</th>
                <th>Type</th>
                <th>Initial setting</th>
                <th>&nbsp;</th>
              </tr></thead>
              <tbody id="group-list"><tr id="group-new">
                <td>New:</td>
                <td>
                  <select name="new-type" id="new-type">
                    <option value="none" selected disabled>---  Select type  ---</option>
                    <option value="duration">Set duration</option>
                    <option value="time">Set 24-hour time</option>
                  </select>
                </td>
                <td class="one-line" role="status">
                  <input type="number" placeholder="H" min="0" id="new-h"> :
                  <input type="number" placeholder="M" min="0" max="59" id="new-m" onchange="pad_field(this)"> :
                  <input type="number" placeholder="S" min="0" max="59" id="new-s" onchange="pad_field(this)">
                </td>
                <td><i class="material-icons button" onclick="group_add_new()" title="Add new entry">add</i></td>
              </tr>
            </tbody></table>
          </div>
        </div>
      </form>
    </div>
  </div>
  <div id="controls">
    <i class="material-icons button" id="edit-button" onclick="edit()" title="Set timer">edit</i>
    <i class="material-icons button hide" id="save-button" onclick="save()" title="Save settings">save</i>
    <i class="material-icons button hide" id="delete-button" onclick="delete_settings()" title="Delete saved settings">delete</i>
    <i class="material-icons button" id="play-button" onclick="start_timer()" title="Start">play_arrow</i>
    <i class="material-icons button hide" id="pause-button" onclick="pause_timer()" title="Pause">pause</i>
    <i class="material-icons button hide" id="stop-button" onclick="stop_timer()" title="Reset">refresh</i>
    <i class="material-icons button hide" id="schedule-prev-button" onclick="group_prev()" title="Move to the previous item in the group">navigate_before</i>
    <i class="material-icons button hide" id="schedule-next-button" onclick="group_next()" title="Move to the next item in the group">navigate_next</i>
    <i class="material-icons button" id="fullscreen" onclick="toggle_fullscreen()" title="Toggle fullscreen">fullscreen</i>
  </div>
</div></div>

<div id="help-text">
<h2>Help/about</h2>
<p>This timer is useful for situations where you want a countdown timer but no audible alarm. I wrote it for timing speeches. It can count down from a specified duration (e.g., 30 minutes), or count down to a specified time in 24-hour format (e.g., 20:00).</p>
<p>This timer uses a visible alarm system. First, as time runs down, the background color changes from black to yellow to red to help remind the user of the remaining time. When time expires, the background flashes yellow and red. <i>Importantly, the timer keeps going to negative numbers,</i> so if you go overtime, you’ll know by how much.</p>
<p>To use the timer, press the edit button (<i class="material-icons">edit</i>) to set the time, then press start (<i class="material-icons">play_arrow</i>). You will probably want to click the full screen button (<i class="material-icons">fullscreen</i>) to get a more usable display. It’ll work on any screen, mobile or non-mobile. However, it’s up to you to prevent the screen from turning off while the timer is running.</p>
<p><b>Keyboard shortcuts:</b> <span class="key">Space</span> starts and stops the timer. <span class="key"><i class="material-icons" style="font-variant: initial">navigate_before</i></span> and <span class="key"><i class="material-icons" style="font-variant: initial">navigate_next</i></span> switch between timers when you’re using timer groups, and <span class="key">E</span> edits the timer and saves your edits.</p>
<h3>API</h3>
<p>You can add text to the URL to control the timer. That way, you can set the
  timer to automatically start with certain parameters regardless of the
  browser. To enable this, insert a <code>?</code> at the end of the URL
  displayed in the address bar. Afterwards, insert any of the options below,
  separated by <code>&amp;</code>.</p>
<ul>
  <li><code>time</code>: Set this to the time to count down to and
    automatically start the timer. This is in h:m:s format or m:s format. For
    example, <code>time=1:00</code> will set the timer for one minute and no
    seconds, while <code>time=3:21:54</code> will set it for three hours, 21
    minutes, and 54 seconds.</li>
  <li><code>count_to_time</code>: If <code>false</code>, the default, the
    timer will interpret the given time as a duration. If
    <code>true</code>, the timer will interpret the time as a clock time
    to count to. This option is meaningless unless the <code>time</code>
    option is also given.</li>
  <li><code>fullscreen</code>: If set to <code>true</code>, the timer will
    automatically enter full screen mode. <b>Important:</b> Browser security
    rules mean that it's not possible to automatically enter true full screen
    mode. The only way to do that is through true user interaction. This mode
    does a mostly-full screen that leaves the browser chrome still visible.</li>
  <li><code>no_color</code>: If set to <code>true</code>, the timer won't change
    color when time is running low or is expired. I added this mode to support
    using the timer in a video setting where the background is keyed out and
    color changes just don't work.</li>
</ul>
<h4>Examples</h4>
<ul>
  <li>Start a full screen timer for 30 minutes:
    <code>https://www.scottseverance.us/timer/?time=30:00&amp;fullscreen=true</code></li>
  <li>Start a full screen timer to count to 10:00 am:
    <code>https://www.scottseverance.us/timer/?time=10:00:00&amp;count_to_time=true&amp;fullscreen=true</code></li>
  <li>Start a 3.5-minute timer, not full screen:
    <code>https://www.scottseverance.us/timer/?time=3:30</code></li>
</ul>
</div>
<div id="no-auto-fullscreen" class="snackbar">Automatically entering full
  screen mode isn't permitted for security reasons. For true full screen,
  manually exit and re-enter full screen mode.</div>