Roll20 Timekeeper Player

Timekeeper for Player

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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 }));
        }
    });
})();