Universal Floating Timer

Adds a draggable timer/stopwatch widget to every website that saves minimized state, position, mode, and time.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Universal Floating Timer
// @namespace    Violentmonkey Scripts
// @version      1.2.0
// @description  Adds a draggable timer/stopwatch widget to every website that saves minimized state, position, mode, and time.
// @author       Mateo Benavides-Kastner
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  if (window.top !== window.self) return;
  if (document.getElementById("mateo-universal-timer")) return;

  const STORAGE_KEY = "mateo-universal-floating-timer-state-v2";

  function loadState() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
    } catch {
      return {};
    }
  }

  const saved = loadState();
  const isMinimized = saved.minimized !== undefined ? saved.minimized : true;

  const timer = document.createElement("div");
  timer.id = "mateo-universal-timer";

  if (isMinimized) {
    timer.classList.add("mut-minimized");
  }

  timer.innerHTML = `
    <div id="mut-header">
      <span>⏱ Timer</span>
      <button id="mut-toggle" type="button">${isMinimized ? "+" : "−"}</button>
    </div>

    <div id="mut-body">
      <div id="mut-mode-row">
        <span>Stopwatch</span>
        <label id="mut-switch">
          <input id="mut-mode-switch" type="checkbox">
          <span id="mut-slider"></span>
        </label>
        <span>Timer</span>
      </div>

      <div id="mut-display">00:00:00</div>

      <div id="mut-timer-inputs">
        <input id="mut-hours" type="number" min="0" max="99" value="0" title="Hours">
        <span>:</span>
        <input id="mut-minutes" type="number" min="0" max="59" value="5" title="Minutes">
        <span>:</span>
        <input id="mut-seconds" type="number" min="0" max="59" value="0" title="Seconds">
      </div>

      <div id="mut-buttons">
        <button id="mut-start" type="button">Start</button>
        <button id="mut-stop" type="button">Stop</button>
        <button id="mut-reset" type="button">Reset</button>
      </div>
    </div>
  `;

  document.body.appendChild(timer);

  const style = document.createElement("style");
  style.textContent = `
    #mateo-universal-timer {
      position: fixed;
      top: ${typeof saved.top === "number" ? saved.top + "px" : "20px"};
      left: ${typeof saved.left === "number" ? saved.left + "px" : "50%"};
      transform: ${typeof saved.left === "number" ? "none" : "translateX(-50%)"};
      width: 280px;
      background: #1f1f1f;
      color: white;
      border-radius: 14px;
      box-shadow: 0 8px 30px rgba(0,0,0,0.4);
      z-index: 2147483647;
      font-family: Arial, sans-serif;
      overflow: hidden;
      user-select: none;
    }

    #mut-header {
      background: #111;
      padding: 10px 12px;
      cursor: move;
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-weight: bold;
      font-size: 14px;
    }

    #mut-toggle {
      background: #333;
      color: white;
      border: none;
      border-radius: 6px;
      width: 28px;
      height: 24px;
      cursor: pointer;
      font-size: 18px;
      line-height: 18px;
    }

    #mut-body {
      padding: 12px;
    }

    #mut-mode-row {
      display: flex;
      justify-content: center;
      align-items: center;
      gap: 8px;
      font-size: 12px;
      margin-bottom: 10px;
      color: #ccc;
    }

    #mut-switch {
      position: relative;
      display: inline-block;
      width: 46px;
      height: 24px;
    }

    #mut-switch input {
      opacity: 0;
      width: 0;
      height: 0;
    }

    #mut-slider {
      position: absolute;
      cursor: pointer;
      inset: 0;
      background: #444;
      border-radius: 999px;
      transition: 0.2s;
    }

    #mut-slider::before {
      content: "";
      position: absolute;
      height: 18px;
      width: 18px;
      left: 3px;
      bottom: 3px;
      background: white;
      border-radius: 50%;
      transition: 0.2s;
    }

    #mut-mode-switch:checked + #mut-slider {
      background: #0078ff;
    }

    #mut-mode-switch:checked + #mut-slider::before {
      transform: translateX(22px);
    }

    #mut-display {
      background: #000;
      color: #00ff88;
      border-radius: 8px;
      padding: 12px;
      text-align: center;
      font-size: 30px;
      font-weight: bold;
      letter-spacing: 1px;
      margin-bottom: 10px;
    }

    #mut-timer-inputs {
      display: none;
      justify-content: center;
      align-items: center;
      gap: 5px;
      margin-bottom: 10px;
    }

    #mut-timer-inputs input {
      width: 52px;
      height: 32px;
      border: none;
      border-radius: 7px;
      background: #000;
      color: #00ff88;
      text-align: center;
      font-size: 15px;
      outline: none;
    }

    #mut-buttons {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 7px;
    }

    #mut-buttons button {
      height: 34px;
      border: none;
      border-radius: 8px;
      background: #333;
      color: white;
      cursor: pointer;
      font-size: 14px;
    }

    #mut-buttons button:hover,
    #mut-toggle:hover {
      background: #555;
    }

    #mut-start {
      background: #137333 !important;
    }

    #mut-stop {
      background: #9a3412 !important;
    }

    #mut-reset {
      background: #c0392b !important;
    }

    .mut-timer-mode #mut-timer-inputs {
      display: flex;
    }

    .mut-minimized {
      width: 130px !important;
    }

    .mut-minimized #mut-body {
      display: none;
    }
  `;
  document.head.appendChild(style);

  const header = document.getElementById("mut-header");
  const toggle = document.getElementById("mut-toggle");
  const display = document.getElementById("mut-display");
  const modeSwitch = document.getElementById("mut-mode-switch");
  const hoursInput = document.getElementById("mut-hours");
  const minutesInput = document.getElementById("mut-minutes");
  const secondsInput = document.getElementById("mut-seconds");
  const startBtn = document.getElementById("mut-start");
  const stopBtn = document.getElementById("mut-stop");
  const resetBtn = document.getElementById("mut-reset");

  let mode = saved.mode === "timer" ? "timer" : "stopwatch";
  let running = saved.running === true;
  let intervalId = null;

  let stopwatchSeconds = Number(saved.stopwatchSeconds) || 0;
  let timerSeconds = Number(saved.timerSeconds) || 5 * 60;
  let timerOriginalSeconds = Number(saved.timerOriginalSeconds) || 5 * 60;

  hoursInput.value = Number(saved.hours ?? 0);
  minutesInput.value = Number(saved.minutes ?? 5);
  secondsInput.value = Number(saved.seconds ?? 0);

  if (mode === "timer") {
    timer.classList.add("mut-timer-mode");
    modeSwitch.checked = true;
  }

  function pad(number) {
    return String(number).padStart(2, "0");
  }

  function formatTime(totalSeconds) {
    totalSeconds = Math.max(0, Math.floor(totalSeconds));

    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;

    return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
  }

  function getInputSeconds() {
    const hours = Math.max(0, Number(hoursInput.value) || 0);
    const minutes = Math.min(59, Math.max(0, Number(minutesInput.value) || 0));
    const seconds = Math.min(59, Math.max(0, Number(secondsInput.value) || 0));

    hoursInput.value = hours;
    minutesInput.value = minutes;
    secondsInput.value = seconds;

    return hours * 3600 + minutes * 60 + seconds;
  }

  function saveState() {
    const rect = timer.getBoundingClientRect();

    localStorage.setItem(STORAGE_KEY, JSON.stringify({
      minimized: timer.classList.contains("mut-minimized"),
      mode,
      running,
      stopwatchSeconds,
      timerSeconds,
      timerOriginalSeconds,
      hours: Number(hoursInput.value) || 0,
      minutes: Number(minutesInput.value) || 0,
      seconds: Number(secondsInput.value) || 0,
      left: rect.left,
      top: rect.top
    }));
  }

  function updateDisplay() {
    display.textContent = mode === "stopwatch"
      ? formatTime(stopwatchSeconds)
      : formatTime(timerSeconds);

    saveState();
  }

  function stop() {
    running = false;

    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }

    saveState();
  }

  function start() {
    if (running) return;

    if (mode === "timer" && timerSeconds <= 0) {
      timerSeconds = getInputSeconds();
      timerOriginalSeconds = timerSeconds;
    }

    if (mode === "timer" && timerSeconds <= 0) return;

    running = true;
    saveState();

    intervalId = setInterval(() => {
      if (mode === "stopwatch") {
        stopwatchSeconds++;
      } else {
        timerSeconds--;

        if (timerSeconds <= 0) {
          timerSeconds = 0;
          updateDisplay();
          stop();
          alert("Timer done!");
          return;
        }
      }

      updateDisplay();
    }, 1000);
  }

  function reset() {
    stop();

    if (mode === "stopwatch") {
      stopwatchSeconds = 0;
    } else {
      timerSeconds = getInputSeconds();
      timerOriginalSeconds = timerSeconds;
    }

    updateDisplay();
  }

  function setMode(newMode) {
    stop();

    mode = newMode;

    if (mode === "timer") {
      timer.classList.add("mut-timer-mode");
      timerSeconds = getInputSeconds();
      timerOriginalSeconds = timerSeconds;
    } else {
      timer.classList.remove("mut-timer-mode");
    }

    updateDisplay();
  }

  function resumeIfNeeded() {
    if (!running) return;

    running = false;
    start();
  }

  toggle.addEventListener("click", () => {
    timer.classList.toggle("mut-minimized");
    toggle.textContent = timer.classList.contains("mut-minimized") ? "+" : "−";
    saveState();
  });

  modeSwitch.addEventListener("change", () => {
    setMode(modeSwitch.checked ? "timer" : "stopwatch");
  });

  startBtn.addEventListener("click", start);
  stopBtn.addEventListener("click", stop);
  resetBtn.addEventListener("click", reset);

  [hoursInput, minutesInput, secondsInput].forEach((input) => {
    input.addEventListener("input", () => {
      if (!running && mode === "timer") {
        timerSeconds = getInputSeconds();
        timerOriginalSeconds = timerSeconds;
        updateDisplay();
      }
    });
  });

  let dragging = false;
  let offsetX = 0;
  let offsetY = 0;

  header.addEventListener("mousedown", (event) => {
    if (event.target === toggle) return;

    dragging = true;
    const rect = timer.getBoundingClientRect();

    timer.style.left = `${rect.left}px`;
    timer.style.top = `${rect.top}px`;
    timer.style.right = "auto";
    timer.style.transform = "none";

    offsetX = event.clientX - rect.left;
    offsetY = event.clientY - rect.top;
  });

  document.addEventListener("mousemove", (event) => {
    if (!dragging) return;

    timer.style.left = `${event.clientX - offsetX}px`;
    timer.style.top = `${event.clientY - offsetY}px`;
  });

  document.addEventListener("mouseup", () => {
    if (dragging) {
      dragging = false;
      saveState();
    }
  });

  window.addEventListener("beforeunload", saveState);

  updateDisplay();
  resumeIfNeeded();
})();