Roll20 Timekeeper Player

Timekeeper for Player

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Roll20 Timekeeper Player
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  Timekeeper for Player
// @author       @TheMerryTavern
// @match        https://app.roll20.net/editor/*
// @grant        none
// @license      MIT
// ==/UserScript==

/*
   MIT License

   Copyright (c) 2026 The Merry Tavern

   Permission is hereby granted, free of charge, to any person obtaining a copy
   of this software and associated documentation files (the "Software"), to deal
   in the Software without restriction, including without limitation the rights
   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   copies of the Software, and to permit persons to whom the Software is
   furnished to do so, subject to the following conditions:

   The above copyright notice and this permission notice shall be included in all
   copies or substantial portions of the Software.

   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
   SOFTWARE.
*/

(function() {
    'use strict';
    if (location.pathname !== "/editor/") return;

    const STORAGE_KEY_POS = 'r20_clock_position';
    const STORAGE_KEY_NOTES = 'r20_clock_notes';

    // ────────────────────────────────
    // BAR WITH TIME
    // ────────────────────────────────
    const bar = document.createElement("div");
    bar.style.position = "fixed";
    bar.style.top = "6px";
    bar.style.left = "50%";
    bar.style.transform = "translateX(-50%)";
    bar.style.width = "260px";
    bar.style.height = "36px";
    bar.style.background = "rgba(0,0,0,0.7)";
    bar.style.color = "white";
    bar.style.fontSize = "18px";
    bar.style.fontWeight = "700";
    bar.style.fontFamily = "monospace";
    bar.style.display = "flex";
    bar.style.alignItems = "center";
    bar.style.justifyContent = "center";
    bar.style.padding = "0 10px";
    bar.style.borderRadius = "6px";
    bar.style.zIndex = "2147483647";
    bar.style.boxShadow = "0 2px 8px rgba(0,0,0,0.6)";
    document.body.appendChild(bar);

    const savedPos = JSON.parse(localStorage.getItem(STORAGE_KEY_POS) || "null");
    if (savedPos && savedPos.top && savedPos.left) {
        bar.style.top = savedPos.top;
        bar.style.left = savedPos.left;
        bar.style.transform = "";
    }

    const timeBox = document.createElement("div");
    timeBox.style.fontSize = "18px";
    timeBox.style.fontWeight = "900";
    timeBox.style.letterSpacing = "1px";
    timeBox.textContent = "12:00";
    timeBox.style.position = "absolute";
    timeBox.style.left = "50%";
    timeBox.style.transform = "translateX(-50%)";
    bar.appendChild(timeBox);

    const dayEmoji = document.createElement("span");
    dayEmoji.style.fontSize = "20px";
    dayEmoji.style.position = "absolute";
    dayEmoji.style.left = "calc(50% + 50px)";
    dayEmoji.style.transform = "translateX(-50%)";
    dayEmoji.textContent = "☀️";
    bar.appendChild(dayEmoji);

    function getDayEmoji(hour) {
        if (hour >= 5 && hour < 8) return "🌅";
        if (hour >= 8 && hour < 12) return "☀️";
        if (hour >= 12 && hour < 14) return "🌞";
        if (hour >= 14 && hour < 18) return "☀️";
        if (hour >= 18 && hour < 21) return "🌇";
        return "🌙";
    }

    let time = { h: 12, m: 0 };
    let calendar = { day: 1, month: 1, year: 1492, season: "Deepwinter" };
    let notes = JSON.parse(localStorage.getItem(STORAGE_KEY_NOTES) || "{}");

    function updateTimeDisplay() {
        timeBox.textContent = String(time.h).padStart(2, '0') + ":" + String(time.m).padStart(2, '0');
        const emoji = getDayEmoji(time.h);
        if (emoji !== dayEmoji.textContent) {
            dayEmoji.textContent = emoji;
        }
    }
    updateTimeDisplay();

    function computeSeason(m) {
        if ([1,2,12].includes(m)) return "Deepwinter";
        if ([3,4,5].includes(m)) return "Spring";
        if ([6,7,8].includes(m)) return "Summer";
        if ([9,10,11].includes(m)) return "Autumn";
        return "Unknown";
    }

    // ────────────────────────────────
    // CHAT LISTENING FOR SYNC
    // ────────────────────────────────
    const observer = new MutationObserver(() => {
        document.querySelectorAll('#textchat .message, #textchat .content, #textchat p').forEach(el => {
            const text = el.textContent?.trim();
            if (text) {
                if (text.includes('time ')) {
                    const timePart = text.split('time ')[1]?.trim();
                    const parts = timePart.split(':').map(s => parseInt(s.trim(), 10));
                    if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
                        time.h = parts[0] % 24;
                        time.m = parts[1] % 60;
                        updateTimeDisplay();
                    }
                } else if (text.includes('date ')) {
                    const datePart = text.split('date ')[1]?.trim();
                    const parts = datePart.split('/').map(s => parseInt(s.trim(), 10));
                    if (parts.length === 3 && parts.every(n => !isNaN(n))) {
                        calendar.day = parts[0];
                        calendar.month = parts[1];
                        calendar.year = parts[2];
                        calendar.season = computeSeason(calendar.month);
                        renderCalendar();
                    }
                }
            }
        });
    });

    const chatContainer = document.querySelector('#textchat') || document.body;
    observer.observe(chatContainer, { childList: true, subtree: true });

    // ────────────────────────────────
    // CALENDAR PANEL
    // ────────────────────────────────
    const calendarPanel = document.createElement("div");
    calendarPanel.style.position = "absolute";
    calendarPanel.style.width = "320px";
    calendarPanel.style.maxWidth = "90vw";
    calendarPanel.style.background = "rgba(0,0,0,0.7)";
    calendarPanel.style.border = "1px solid rgba(255,255,255,0.06)";
    calendarPanel.style.borderRadius = "8px";
    calendarPanel.style.padding = "8px";
    calendarPanel.style.display = "none";
    calendarPanel.style.color = "white";
    calendarPanel.style.zIndex = "2147483646";
    calendarPanel.style.boxShadow = "0 6px 24px rgba(0,0,0,0.6)";
    calendarPanel.style.transition = "opacity 0.18s ease, transform 0.18s ease";
    document.body.appendChild(calendarPanel);

    const calendarIcon = document.createElement("div");
    calendarIcon.textContent = "📅";
    calendarIcon.style.position = "absolute";
    calendarIcon.style.left = "35px";
    calendarIcon.style.cursor = "pointer";
    calendarIcon.title = "Click to open calendar";
    bar.appendChild(calendarIcon);

    let viewDate = { ...calendar };
    let viewIsSynced = true;

    function cloneDate(src) {
        return { day: Number(src.day||1), month: Number(src.month||1), year: Number(src.year||1492), season: src.season || "" };
    }

    function renderCalendar() {
        const months = ["Hammer","Alturiak","Ches","Tarsakh","Mirtul","Kythorn","Flamerule","Eleasis","Eleint","Marpenoth","Uktar","Nightal"];
        const daysOfWeek = ["Starday","Sunday","Moonday","Lunesday","Trewsday","Thurnday","Fryday"];
        if (viewDate.day < 1) viewDate.day = 1;
        if (viewDate.day > 30) viewDate.day = 30;
        const daysHeader = daysOfWeek.map(n => `<div style="font-size:11px;color:#bbb;padding:2px 0;">${n.slice(0,3)}</div>`).join("");
        const daysGrid = Array.from({length:30}, (_,i) => i+1).map(day => {
            const key = `${viewDate.year}-${viewDate.month}-${day}`;
            const hasNote = !!notes[key];
            let dot = "";
            if (viewDate.month === calendar.month && viewDate.year === calendar.year && day === calendar.day) {
                dot = '<span style="display:inline-block;width:8px;height:8px;background:gold;border-radius:50%;margin-left:6px;vertical-align:middle;"></span>';
            } else if (hasNote) {
                dot = '<span style="display:inline-block;width:8px;height:8px;background:deepskyblue;border-radius:50%;margin-left:6px;vertical-align:middle;"></span>';
            }
            return `<div data-day="${day}" style="padding:6px;cursor:pointer;border-radius:6px;min-height:28px;display:flex;align-items:center;justify-content:center;">${day}${dot}</div>`;
        }).join("");
        calendarPanel.innerHTML = `<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:6px;font-size:13px;text-align:center;">${daysHeader}${daysGrid}</div>`;
        const headerDiv = document.createElement('div');
        headerDiv.style.display = 'flex';
        headerDiv.style.justifyContent = 'center';
        headerDiv.style.alignItems = 'center';
        headerDiv.style.marginBottom = '6px';
        headerDiv.style.position = 'relative';
        headerDiv.style.padding = '0 20px';
        const prevBtn = document.createElement('button');
        prevBtn.textContent = '◀';
        prevBtn.style.position = 'absolute';
        prevBtn.style.left = '8px';
        prevBtn.style.background = 'transparent';
        prevBtn.style.border = 'none';
        prevBtn.style.color = 'white';
        prevBtn.style.cursor = 'pointer';
        prevBtn.addEventListener('click', ev => {
            ev.stopPropagation();
            viewDate.month--;
            if (viewDate.month < 1) { viewDate.month = 12; viewDate.year--; }
            viewDate.season = computeSeason(viewDate.month);
            viewIsSynced = false;
            renderCalendar();
        });
        const nextBtn = document.createElement('button');
        nextBtn.textContent = '▶';
        nextBtn.style.position = 'absolute';
        nextBtn.style.right = '8px';
        nextBtn.style.background = 'transparent';
        nextBtn.style.border = 'none';
        nextBtn.style.color = 'white';
        nextBtn.style.cursor = 'pointer';
        nextBtn.addEventListener('click', ev => {
            ev.stopPropagation();
            viewDate.month++;
            if (viewDate.month > 12) { viewDate.month = 1; viewDate.year++; }
            viewDate.season = computeSeason(viewDate.month);
            viewIsSynced = false;
            renderCalendar();
        });
        const titleSpan = document.createElement('span');
        titleSpan.style.fontWeight = '700';
        titleSpan.style.textAlign = 'center';
        titleSpan.style.whiteSpace = 'nowrap';
        const monthName = (months[viewDate.month - 1] || '???');
        const seasonName = viewDate.season || computeSeason(viewDate.month);
        titleSpan.textContent = `📅 ${monthName} ${viewDate.year} (${seasonName})`;
        headerDiv.appendChild(prevBtn);
        headerDiv.appendChild(titleSpan);
        headerDiv.appendChild(nextBtn);
        calendarPanel.insertBefore(headerDiv, calendarPanel.firstChild);
        calendarPanel.querySelectorAll('[data-day]').forEach(el => {
            el.addEventListener('click', () => { openNoteWindow(parseInt(el.getAttribute('data-day'), 10)); });
        });

        // Add export/import buttons
        const footer = document.createElement('div');
        footer.style.display = 'flex';
        footer.style.justifyContent = 'space-between';
        footer.style.marginTop = '12px';

        const exportBtn = document.createElement('button');
        exportBtn.textContent = 'Export Notes';
        exportBtn.style.padding = '6px 12px';
        exportBtn.style.background = '#444';
        exportBtn.style.color = 'white';
        exportBtn.style.border = 'none';
        exportBtn.style.borderRadius = '4px';
        exportBtn.style.cursor = 'pointer';
        exportBtn.addEventListener('click', () => {
            const json = JSON.stringify(notes, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = 'calendar_notes.json';
            a.click();
        });

        const importFileBtn = document.createElement('button');
        importFileBtn.textContent = 'Import from file';
        importFileBtn.style.padding = '6px 12px';
        importFileBtn.style.background = '#444';
        importFileBtn.style.color = 'white';
        importFileBtn.style.border = 'none';
        importFileBtn.style.borderRadius = '4px';
        importFileBtn.style.cursor = 'pointer';
        importFileBtn.addEventListener('click', () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.json';
            input.style.display = 'none';
            input.addEventListener('change', (e) => {
                const file = e.target.files[0];
                if (!file) return;
                const reader = new FileReader();
                reader.onload = (event) => {
                    try {
                        notes = JSON.parse(event.target.result);
                        localStorage.setItem(STORAGE_KEY_NOTES, JSON.stringify(notes));
                        alert("Notes imported from file!");
                        renderCalendar();
                    } catch (err) {
                        alert("Invalid JSON file.");
                    }
                };
                reader.readAsText(file);
            });
            input.click();
        });

        footer.appendChild(exportBtn);
        footer.appendChild(importFileBtn);
        calendarPanel.appendChild(footer);
    }

    calendarIcon.addEventListener('click', () => {
        if (calendarPanel.style.display === 'none' || calendarPanel.style.display === '') {
            const rect = bar.getBoundingClientRect();
            viewDate = cloneDate(calendar);
            viewIsSynced = true;
            calendarPanel.style.display = "block";
            calendarPanel.style.opacity = '0';
            calendarPanel.style.transform = 'translateY(-6px)';
            renderCalendar();
            requestAnimationFrame(() => {
                const panelWidth = calendarPanel.offsetWidth || 320;
                const left = rect.left + (rect.width / 2) - (panelWidth / 2);
                calendarPanel.style.left = Math.max(4, Math.min(left, window.innerWidth - panelWidth - 4)) + "px";
                calendarPanel.style.top = (rect.bottom + 6) + "px";
                calendarPanel.style.opacity = '1';
                calendarPanel.style.transform = 'translateY(0)';
            });
        } else {
            calendarPanel.style.display = "none";
        }
    });

    function openNoteWindow(day) {
        const key = `${viewDate.year}-${viewDate.month}-${day}`;
        const existing = notes[key] || "";
        if (document.querySelector(`[data-note-key="${key}"]`)) {
            const existingWin = document.querySelector(`[data-note-key="${key}"]`);
            const ta = existingWin.querySelector('textarea');
            if (ta) ta.focus();
            return;
        }
        const noteWin = document.createElement('div');
        noteWin.setAttribute('data-note-key', key);
        noteWin.style.position = 'fixed';
        noteWin.style.top = '120px';
        noteWin.style.left = '120px';
        noteWin.style.width = '360px';
        noteWin.style.minWidth = '220px';
        noteWin.style.height = '260px';
        noteWin.style.background = '#0b0b0b';
        noteWin.style.color = 'white';
        noteWin.style.border = '2px solid white';
        noteWin.style.borderRadius = '8px';
        noteWin.style.zIndex = '2147483650';
        noteWin.style.display = 'flex';
        noteWin.style.flexDirection = 'column';
        noteWin.style.resize = 'both';
        noteWin.style.overflow = 'hidden';
        noteWin.style.boxShadow = '0 10px 30px rgba(0,0,0,0.6)';

        const header = document.createElement('div');
        header.style.padding = '6px 8px';
        header.style.cursor = 'grab';
        header.style.background = 'linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0))';
        header.style.borderBottom = '1px solid rgba(255,255,255,0.03)';
        header.style.display = 'flex';
        header.style.justifyContent = 'space-between';
        header.style.alignItems = 'center';
        const title = document.createElement('div');
        title.textContent = `Note: ${day}/${viewDate.month}/${viewDate.year}`;
        title.style.fontSize = '13px';
        title.style.color = 'white';
        title.style.fontWeight = '700';
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✖';
        closeBtn.title = 'Close without saving';
        closeBtn.style.background = 'transparent';
        closeBtn.style.border = 'none';
        closeBtn.style.color = 'white';
        closeBtn.style.cursor = 'pointer';
        closeBtn.style.fontSize = '14px';
        header.appendChild(title);
        header.appendChild(closeBtn);
        noteWin.appendChild(header);

        const ta = document.createElement('textarea');
        ta.value = existing;
        ta.style.flex = '1';
        ta.style.width = '100%';
        ta.style.background = '#0f0f0f';
        ta.style.color = 'white';
        ta.style.border = 'none';
        ta.style.outline = 'none';
        ta.style.padding = '8px';
        ta.style.resize = 'none';
        ta.style.fontFamily = 'monospace';
        noteWin.appendChild(ta);

        const footer = document.createElement('div');
        footer.style.padding = '8px';
        footer.style.background = 'rgba(255,255,255,0.05)';
        footer.style.display = 'flex';
        footer.style.justifyContent = 'flex-end';
        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.style.padding = '6px 16px';
        saveBtn.style.background = '#2a2';
        saveBtn.style.color = 'white';
        saveBtn.style.border = 'none';
        saveBtn.style.borderRadius = '4px';
        saveBtn.style.cursor = 'pointer';
        saveBtn.style.fontWeight = '700';
        footer.appendChild(saveBtn);
        noteWin.appendChild(footer);

        document.body.appendChild(noteWin);
        ta.focus();

        let dragging = false, dx = 0, dy = 0;
        header.addEventListener('mousedown', (e) => {
            dragging = true;
            header.style.cursor = 'grabbing';
            dx = e.clientX - noteWin.getBoundingClientRect().left;
            dy = e.clientY - noteWin.getBoundingClientRect().top;
            e.preventDefault();
        });
        const onMove = (e) => {
            if (!dragging) return;
            let nx = e.clientX - dx;
            let ny = e.clientY - dy;
            nx = Math.max(4, Math.min(nx, window.innerWidth - noteWin.offsetWidth - 4));
            ny = Math.max(4, Math.min(ny, window.innerHeight - noteWin.offsetHeight - 4));
            noteWin.style.left = nx + 'px';
            noteWin.style.top = ny + 'px';
        };
        const onUp = () => { dragging = false; header.style.cursor = 'grab'; };
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onUp);

        const saveAndClose = () => {
            notes[key] = ta.value;
            localStorage.setItem(STORAGE_KEY_NOTES, JSON.stringify(notes));
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onUp);
            if (noteWin.parentNode) noteWin.parentNode.removeChild(noteWin);
            renderCalendar();
        };

        const closeWithoutSave = () => {
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('mouseup', onUp);
            if (noteWin.parentNode) noteWin.parentNode.removeChild(noteWin);
        };

        saveBtn.addEventListener('click', saveAndClose);
        closeBtn.addEventListener('click', closeWithoutSave);

        setTimeout(() => {
            function onDocDown(ev) {
                if (!noteWin.contains(ev.target)) {
                    closeWithoutSave();
                    document.removeEventListener('mousedown', onDocDown);
                }
            }
            document.addEventListener('mousedown', onDocDown);
        }, 0);
    }

    let isDragging = false, dragOffX = 0, dragOffY = 0;
    bar.addEventListener('mousedown', ev => {
        isDragging = true;
        dragOffX = ev.clientX - bar.getBoundingClientRect().left;
        dragOffY = ev.clientY - bar.getBoundingClientRect().top;
    });
    window.addEventListener('mousemove', ev => {
        if (!isDragging) return;
        let nx = ev.clientX - dragOffX;
        let ny = ev.clientY - dragOffY;
        nx = Math.max(0, Math.min(nx, window.innerWidth - bar.offsetWidth));
        ny = Math.max(0, Math.min(ny, window.innerHeight - bar.offsetHeight));
        bar.style.left = nx + "px";
        bar.style.top = ny + "px";
        bar.style.transform = "";
        if (calendarPanel.style.display === 'block') {
            const panelWidth = calendarPanel.offsetWidth || 320;
            const left = nx + (bar.offsetWidth / 2) - (panelWidth / 2);
            calendarPanel.style.left = Math.max(4, Math.min(left, window.innerWidth - panelWidth - 4)) + "px";
            calendarPanel.style.top = (ny + bar.offsetHeight + 6) + "px";
        }
    });
    window.addEventListener('mouseup', () => {
        if (isDragging) {
            isDragging = false;
            localStorage.setItem(STORAGE_KEY_POS, JSON.stringify({ top: bar.style.top, left: bar.style.left }));
        }
    });
})();