JavaScript カウントダウンタイマー

数年前の作りかけスクリプトを見つけたのでとりあえず動くようにしてみました。
![01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/6828eee7-f1b0-ead1-ecef-7745bc986f27.jpeg)
計測したい時間は数字ボタンで入力します。
例えば15分計りたい場合は[1][5][分][スタート]でも[9][0][0][秒][スタート]でも[.][2][5][時間][スタート]でも構いません。

計測中および一時停止中どちらでも、数字ボタンからの入力で計測時間の追加が行なえます。
入力数字の表示部分をクリックすると符号反転し(背景が赤くなります)、設定済みの時間から入力した時間を引くことができます。

計測中にブラウザを閉じてしまっても再度画面を表示させた際にまだ計測終了時間に達していなければ続行が可能ですが、閉じている間に計測終了時間になっても通知はできません。

[動作サンプルはこちら](https://akebi.jp/temp/timer/)

**HTML**

~~~html:index.html
<html>
<head>
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8'>
<title>Countdown Timer</title>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
<link rel='stylesheet' type='text/css' href='./timer.css' media='all'>
</head>
<body width='100%' id='mainArea'>

<div class='block1'>
    <div class='blockA'>
        <input type='text' value='0' id='d0' class='setNum' readonly onclick='chgSign()'><br />
        <input type='button' value='7' id='k7' class='numKey' onclick='setNum(7)'><input type='button' value='8' id='k8' class='numKey' onclick='setNum(8)'><input type='button' value='9' id='k9' class='numKey' onclick='setNum(9)'><br />
        <input type='button' value='4' id='k4' class='numKey' onclick='setNum(4)'><input type='button' value='5' id='k5' class='numKey' onclick='setNum(5)'><input type='button' value='6' id='k6' class='numKey' onclick='setNum(6)'><br />
        <input type='button' value='1' id='k1' class='numKey' onclick='setNum(1)'><input type='button' value='2' id='k2' class='numKey' onclick='setNum(2)'><input type='button' value='3' id='k3' class='numKey' onclick='setNum(3)'><br />
        <input type='button' value='0' id='k0' class='numKey' onclick='setNum(0)'><input type='button' value='.' id='kp' class='numKey' onclick='setNum(".")'><input type='button' value='C' id='kr' class='numKey clearButton' onclick='reset()'><br />

        <input type='button' value='時間' class='numKey timeButton' onclick='addTotal("h")' id='kh'><input type='button' value='分' class='numKey timeButton' onclick='addTotal("m")' id='km'><input type='button' value='秒' class='numKey timeButton' onclick='addTotal("s")' id='ks'>
    </div>
    <br />
    <div class='blockB'>
        <input type='text' value='0:00:00.0' id='d1' class='progressTime' readonly>

        <div id='progressArea'>
	        <span id='progressBar'></span>
        </div>

        <span class='buttonArea'>
	        <input type='button' value='リセット' id='kac' class='resetButton' onclick='allClear()'>
	        <input type='button' value='スタート' id='kss' class='startButton' onclick='countStart()'>
        </span>

    </div>
</div>

<input type='button' value='' id='krs' class='resumeButton'>

<input type='button' id='dummy' style='position: fixed; opacity: 0;'>

<script src='./timer.js' type='text/javascript'></script>

</body>
</html>
~~~

**JavaScript**

~~~js:timer.js
'use strict';

let totalSec = 0;
let allTotalSec = 0;
let countDownSec = 0;
let startMs = 0;
let countId;
let endId;
let onKeyFlg = 0;
let sign = 1;

const d = function(id) { return document.getElementById(id);}

const seList = {
    seClick : 'sound/click.mp3',
    seAlarm : 'sound/alarm.mp3',
};

window.AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = (window.AudioContext !== undefined) ? new AudioContext() : undefined;

const getAudioBuffer = function(url, fn) {  
    const req = new XMLHttpRequest();
    req.responseType = 'arraybuffer';
    req.onreadystatechange = function() {
        if (req.readyState === 4) {
            if (req.status === 0 || req.status === 200) {
                audioContext.decodeAudioData(req.response, function(buffer) {
                    fn(buffer);
                });
            }
        }
    };
    req.open('GET', url, true);
    req.send('');
};

const playSound = function(buffer) {
    const source = audioContext.createBufferSource();
    source.buffer = buffer;
    source.connect(audioContext.destination);
    source.start(0);
};

if(window.AudioContext === undefined) {
    let audioTag = '';
    for(let i in seList) {
        audioTag +=
            '<audio id="' + i + '" preload="auto">'+
            '   <source src="' + seList[i] + '" type="audio/mp3">'+
            '</audio>';
    }
    document.write(audioTag);
}

function se(id) {
    if(audioContext !== undefined){
        document.getElementById(id).onclick();
    } else {
        if(id) {
            const _d = document.getElementById(id);
            if(_d.currentTime != 0 || !_d.paused){
                _d.pause();
                _d.currentTime = 0;
            }
            _d.play();
        }
    }
}

// ロード時処理
window.onload = function() {
    if(audioContext !== undefined) {
        for(let i in seList) {
            getAudioBuffer(seList[i], function(buffer) {
                const bufId = i;
                document.querySelector('body').appendChild(document.createElement("div"));
                document.querySelector('body>div:last-of-type').id = bufId;
                document.getElementById(bufId).onclick = function() {
                    playSound(buffer);
                };
            });
        }
    }

    // 画面スワイプによる引きずり防止
    document.addEventListener("touchmove", function(event) {
        event.preventDefault();
    }, { passive: false });

    // localStorageからパラメータ取得
    const storage = localStorage.getItem('timer_params');
    const params = storage !== null ? JSON.parse(storage) : {};

    // 動作中であれば確認画面表示
    // (Web Audio Apiで音を出すための初回アプローチも兼ねる)
    if(params.allTotalSec !== undefined && params.allTotalSec > 0) {
        // storageから取得した値で更新
        allTotalSec = params.allTotalSec;
        totalSec = params.totalSec;
        startMs = params.startMs;

        // 確認画面(実体は全画面拡大したボタン)表示
        d('krs').style.display = 'block';

        // 確認画面の内容更新インターバル
        const tmpId = setInterval(function() {
            let progressSec = (Date.now() - startMs) / 1000;
            countDownSec = params.pause !== undefined ?
                totalSec :
                totalSec - progressSec;
            if(countDownSec <= 0) {
                clearInterval(tmpId);
                keyEventSet();
                d('krs').style.display = 'none';
                allTotalSec =
                totalSec =
                startMs = 0;
                localStorage.removeItem('timer_params');
            }
            d('krs').value =
                (params.pause !== undefined ? '一時停止中 ' : '') +
                timeString(countDownSec) + '：画面を' +
                (window.ontouchstart !== undefined ? 'タップ' : 'クリック') +'で復帰できます';
        }, 100);

        // 確認画面クリック
        d('krs').addEventListener('click', function() {
            clearInterval(tmpId);
            keyEventSet();
            d('krs').style.display = 'none';
            let progressSec = (Date.now() - startMs) / 1000;
            countDownSec = params.pause !== undefined ?
                totalSec :
                totalSec - progressSec;

            if(countDownSec > 0) {
                se('seClick');
                if(params.pause !== undefined) {
                    d('d1').value = timeString(totalSec) + '.' +
                        (totalSec % 1).toFixed(1).slice(-1);
                } else {
                    d('kss').value = 'ストップ';
                    countId = setInterval('progressCount()', 25);
                }
            }
        }, false);
    } else {
        keyEventSet();
    }
};

// 入力プレビューに値設定
function setNum(n) {
    let d0 = d('d0').value;
    if(n == '.' && d0.match(/\./)) return;

    d0 += String(n);
    d0 = d0.replace(/^-/, '')
           .replace(/^0+$/, '0')
           .replace(/^(\.)/, '0.')
           .replace(/^0(\d+)/, "$1");

    d('d0').value = (sign > 0 ? '' : '-') + d0;
    se('seClick');
}

// 入力プレビュークリア
function reset() {
    d('d0').value = '0';
    if(sign < 0) chgSign();
    se('seClick');
}

// 時間追加
function addTotal(m) {
    const d0 = d('d0').value.replace(/^[\D]/, '');

    let plusSec = 0;
    if(m == 'h') {
        plusSec = Math.floor(d0 * 3600);
    } else if(m == 'm') {
        plusSec = Math.floor(d0 * 60);
    } else {
        plusSec = Math.floor(d0 * 10) / 10;
    }

    if(sign < 0 && plusSec > totalSec) plusSec = totalSec;
    totalSec += (plusSec * sign);
    allTotalSec += (plusSec * sign);

    const params = {
        allTotalSec: allTotalSec,
        totalSec: totalSec,
        startMs: startMs,
    };
    if(!countId) params.pause = 1;
    localStorage.setItem('timer_params', JSON.stringify(params));

    d('d0').value = '0';
    const parsent = totalSec / allTotalSec * 100;

    se('seClick');

    if(sign < 0) chgSign();

    if(countId) return;
    d('progressBar').style.width = parsent + '%';

    const dH = Math.floor(totalSec / 3600);
    const dM = Math.floor(totalSec / 60) % 60;
    const dS = Math.floor(totalSec % 60);
    d('d1').value =
        dH + ':' +
        ('0' + dM).substr(-2) + ':' +
        ('0' + dS).substr(-2) + '.' +
        (totalSec % 1).toFixed(1).slice(-1);
}

// 符号反転
function chgSign() {
    sign *= -1;
    d('d0').style.background = sign === 1 ? '' : '#ff0000';
    d('d0').style.color = sign === 1 ? '' : '#ffffff';
    d('d0').value = (sign > 0 ? '' : '-') + d('d0').value.replace(/^-/, '');
}

// 設定済み時間をクリア
function allClear() {
    totalSec = 0;
    allTotalSec = 0;

    d('d0').value = '0';
    d('d1').value = '0:00:00.0';
    d('progressBar').style.width = '0px';
    if(sign < 0) chgSign();

    localStorage.removeItem('timer_params');
    // 動作中だった場合は解除
    if(countId) {
        clearInterval(countId);
        countId = '';
        d('kss').value = 'スタート';
    }
}

// カウント開始/停止処理
function countStart() {
    if(totalSec <= 0) return;
    se('seClick');

    if(countId) {
        clearInterval(countId);
        countId = '';
        totalSec -= ((Date.now() - startMs) / 1000 );
        d('kss').value = 'スタート';
        localStorage.setItem('timer_params', JSON.stringify({
            allTotalSec: allTotalSec,
            totalSec: totalSec,
            startMs: startMs,
            pause: 1,
        }));
        return;
    }

    d('kss').value = 'ストップ';
    startMs = Date.now();
    if(!countId) countId = setInterval('progressCount()', 25);

    localStorage.setItem('timer_params', JSON.stringify({
        allTotalSec: allTotalSec,
        totalSec: totalSec,
        startMs: startMs,
    }));
}

// カウントダウン表示処理
function progressCount() {
    const progressSec = (Date.now() - startMs) / 1000;

    countDownSec = totalSec - progressSec;
    d('d1').value =
        timeString(countDownSec) + '.'+ (countDownSec % 1).toFixed(1).slice(-1);

    const parsent = (totalSec - progressSec) / allTotalSec * 100;
    d('progressBar').style.width = parsent + '%';

    // 設定時間に達したらインターバル解除してタイムアップ処理へ
    if(countDownSec <= 0) {
        clearInterval(countId);
        countId = '';
        d('kss').value = 'スタート';
        d('progressBar').style.width = '0px';
        totalSec = 0;
        allTotalSec = 0;
        d('d1').value = '0:00:00.0';
        timeUp();
    }
}

// 時間表示用文字列生成
function timeString(sec) {
    sec = Math.floor(sec);
    return Math.floor(sec / 3600) + ':' +
        ('0'+ Math.floor(sec / 60) % 60).slice(-2) + ':' +
        ('0' + (sec % 60)).slice(-2);
}

// タイムアップ
function timeUp() {
    localStorage.removeItem('timer_params');
    se('seAlarm');
    endId = setInterval('alarm()', 1000);
}

// アラーム
function alarm() {
    if(window.ontouchstart === undefined) {
        document.onmousedown = function(e) {
            if(e.type == 'mousedown') {
                if(endId) clearInterval(endId);
                return;
            }
        }
    } else {
        d('mainArea').ontouchstart = function() {
            if(endId) clearInterval(endId);
            return;
        }
    }
    se('seAlarm');
}

// キーボードからの入力対応
function keyEventSet() {
    document.onkeydown = function(e) {
        d('dummy').focus();

        // キーリピートによる連打防止
        if(onKeyFlg) return;
        onKeyFlg = 1;

        const kcode =
            (window.event !== undefined) ? event.keyCode : e.which;

        // 0 ～ 9
        if(kcode >= 48 && kcode <= 57) setNum(kcode - 48);
        if(kcode >= 96 && kcode <= 105) setNum(kcode - 96);
        // .
        else if(kcode === 190 || kcode === 110) setNum('.');
        // c bs del
        else if(kcode === 67 || kcode === 8 || kcode === 46) reset();

        // h:72 m:77 s:83
        else if(kcode === 72) addTotal('h');
        else if(kcode === 77 || kcode === 107) addTotal('m');
        else if(kcode === 83) addTotal('s');

        // r:82 esc:27
        else if(kcode === 82 || kcode == 27) allClear();

        // enter:13 space:32
        else if(kcode === 13 || kcode == 32) countStart();
    }

    document.onkeyup = function() {
        onKeyFlg = 0;
    }
}
~~~

**CSS**

~~~css:timer.css
@media only screen and (orientation : portrait) {
    .setNum {
        text-align: right;
        width: 100%;
        font-size: 30px;
        border-radius: 6px;
        cursor: pointer;
    }

    #progressArea {
        display: table;
        background-color: #cceeff;
        border-radius: 6px;
        border: 1px #555588 solid;
        width: 100%;
        height: 16px;
        overflow: hidden;
        margin-top: 4px;
    }
    #progressArea #progressBar {
        display: inline-block;
        background-color: #4488ff;
        width: 0px;
        height: 100%;
    }

    .numKey {
        width: 33.33%;
        height: 8%;
        font-size: 20px;
        border-radius: 6px;
        border: 1px #444444 solid;
        cursor: pointer;
    }

    .progressTime {
        text-align: right;
        width: 100%;
        font-size: 45px;
        padding-right: 10px;
        border-radius: 6px;
    }

    .buttonArea {
        text-align: center;
        display: block;
        margin-top: 5px;
    }
    .startButton {
        width: 60%;
        height: 12%;
        font-size: 16px;
        cursor: pointer;
    }
    .resetButton {
        width: 36%;
        height: 12%;
        font-size: 16px;
        cursor: pointer;
    }
}

@media only screen and (orientation : landscape) {
    .block1 {
        display: inline-flex;
        width: 100%;
        height: 100%;
    }
    .blockA {
        vertical-align: top;
        width: 40%;
        max-width: 320px;
    }
    .blockB {
        vertical-align: top;
        margin-left: 20px;
    }

    .setNum {
        text-align: right;
        width: 100%;
        font-size: 30px;
        border-radius: 6px;
        max-width: 320px;
        cursor: pointer;
    }
    .numKey {
        width: 33.33%;
        height: 14%;
        font-size: 20px;
        border-radius: 6px;
        border: 1px #444444 solid;
        max-width: 106.6px;
        max-height: 80px;
        cursor: pointer;
    }

    .progressTime {
        text-align: right;
        width: 100%;
        font-size: 45px;
        padding-right: 10px;
        border-radius: 6px;
        max-width: 320px;
    }
    #progressArea {
        display: table;
        background-color: #cceeff;
        border-radius: 6px;
        border: 1px #555588 solid;
        width: 100%;
        height: 16px;
        overflow: hidden;
        margin-top: 4px;
        max-width: 320px;
    }
    #progressArea #progressBar {
        display: inline-block;
        background-color: #4488ff;
        width: 0px;
        height: 100%;
    }

    .buttonArea {
        text-align: center;
        display: block;
        margin-top: 5px;
        max-width: 320px;
    }
    .startButton {
        width: 100%;
        height: 60px;
        font-size: 20px;
        border-radius: 6px;
        cursor: pointer;
    }
    .resetButton {
        width: 100%;
        height: 40px;
        font-size: 16px;
        border-radius: 6px;
        cursor: pointer;
    }
}

#progressArea {
    text-align: left;
}

html {
    touch-action: manipulation;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
}

input {
    -webkit-appearance: none;
    -webkit-user-select: none;
}

.numKey {
    background-color: #fafafa;
}
.clearButton {
    background-color: #e0e0e0;
}
.timeButton {
    background-color: #d0d0d0;
}

.startButton {
    background-color: #e0ffe0;
}
.resetButton {
    background-color: #ffa0a0;
}

#krs {
    display: none;
    position: fixed;
    width: 100%;
    height: 100%;
    background: #fffe;
    top: 0px;
    left: 0px;
    font-size: 20px;
    border: 0px;
    z-index: 10;
    white-space: pre-wrap;
}
~~~

古い書き方をしている部分もありますが、IE11でも動くのでとりあえずそのままにしておきました。

操作音、アラームはそれぞれ**sound**ディレクトリに入れた**click.mp3**、**alarm.mp3**を鳴らすようにしてあります。