- // ==UserScript==
- // @name YouTube Timestamp Manager
- // @namespace http://tampermonkey.net/
- // @version 2.6
- // @description Full-featured timestamp manager with video tracking, inline editing, seeking, and sharp UI.
- // @author Tanuki
- // @match *://www.youtube.com/*
- // @icon https://www.youtube.com/s/desktop/8fa11322/img/favicon_144x144.png
- // @grant none
- // @license MIT
- // ==/UserScript==
-
- (function() {
- 'use strict';
- const DB_NAME = 'TanStampsDB';
- const DB_VERSION = 1;
- const STORE_NAME = 'timestamps';
- const NOTE_PLACEHOLDER = '[No note]'; // Placeholder text for empty notes
-
- let currentVideoId = null;
- let manager = null;
- let noteInput = null;
- let uiContainer = null;
- let progressMarkers = [];
- let currentTooltip = null;
- let updateMarkers = null;
- let timeUpdateInterval = null;
-
- // --- CSS Injection ---
- const style = document.createElement('style');
- style.textContent = `
- .tanuki-ui-container {
- display: inline-flex;
- align-items: center;
- margin-left: 8px;
- }
- .tanuki-timestamp {
- cursor: pointer;
- color: #fff;
- font-family: Arial, sans-serif;
- font-size: 14px;
- line-height: 24px;
- margin: 0 4px;
- user-select: none;
- }
- .tanuki-button {
- background: #333;
- color: #fff;
- border: 1px solid #555;
- padding: 4px 8px;
- border-radius: 0;
- cursor: pointer;
- font-size: 12px;
- transition: background 0.2s, border-color 0.2s;
- margin: 0 2px;
- user-select: none;
- }
- .tanuki-button:hover {
- background: #444;
- border-color: #777;
- }
- .tanuki-button:active {
- background: #222;
- }
- .tanuki-progress-marker {
- position: absolute;
- height: 100%;
- width: 3px;
- background: #3ea6ff;
- box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
- z-index: 999;
- pointer-events: auto;
- transform: translateX(-1.5px);
- cursor: pointer;
- border-radius: 0;
- }
- .tanuki-tooltip {
- position: fixed;
- background: rgba(0, 0, 0, 0.9);
- color: #fff;
- padding: 8px 12px;
- border-radius: 0;
- font-size: 12px;
- white-space: nowrap;
- z-index: 10000;
- pointer-events: none;
- transform: translate(-50%, -100%);
- margin-top: -4px;
- }
- .tanuki-note-input {
- position: fixed;
- background: rgba(30, 30, 30, 0.95);
- color: #fff;
- padding: 20px;
- border-radius: 0;
- z-index: 10000;
- display: flex;
- flex-direction: column;
- align-items: center;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
- border: 1px solid #555;
- }
- .tanuki-note-input input {
- padding: 8px 10px;
- margin-bottom: 12px;
- border: 1px solid #666;
- border-radius: 0;
- width: 220px;
- background: #222;
- color: #fff;
- font-size: 14px;
- }
- .tanuki-note-input input:focus {
- outline: none;
- border-color: #3ea6ff;
- }
- .tanuki-note-input button {
- background: #007bff;
- border: none;
- border-radius: 0;
- padding: 8px 16px;
- cursor: pointer;
- color: #fff;
- font-weight: bold;
- transition: background 0.2s;
- }
- .tanuki-note-input button:hover {
- background: #0056b3;
- }
- .tanuki-manager {
- position: fixed;
- background: rgba(25, 25, 25, 0.97);
- color: #eee;
- padding: 15px 20px 20px 20px;
- border-radius: 0;
- z-index: 99999;
- width: 540px;
- height: 380px;
- overflow: hidden;
- box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
- border: 1px solid #444;
- display: flex;
- flex-direction: column;
- }
- .tanuki-manager-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
- padding-bottom: 10px;
- border-bottom: 1px solid #555;
- flex-shrink: 0;
- }
- .tanuki-manager h3 {
- margin: 0;
- padding: 0;
- border-bottom: none;
- color: #fff;
- font-size: 18px;
- text-align: left;
- flex-grow: 1;
- line-height: 1.2;
- }
- .tanuki-manager button.close-btn {
- background: #666;
- border: 1px solid #888;
- color: #fff;
- font-size: 18px;
- font-weight: bold;
- line-height: 1;
- padding: 3px 7px;
- border-radius: 0;
- cursor: pointer;
- transition: background 0.2s, transform 0.2s;
- position: static;
- margin-left: 10px;
- flex-shrink: 0;
- }
- .tanuki-manager button.close-btn:hover {
- background: #777;
- transform: scale(1.1);
- }
- .tanuki-manager-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- flex-grow: 1;
- overflow-y: auto;
- margin-bottom: 15px;
- }
- .tanuki-timestamp-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- background: #3a3a3a;
- padding: 10px 12px;
- border-radius: 0;
- transition: background 0.15s;
- font-size: 15px;
- }
- .tanuki-timestamp-item:hover {
- background: #4a4a4a;
- }
- .tanuki-timestamp-item span:first-child {
- margin-right: 12px;
- cursor: pointer;
- min-width: 70px;
- text-align: right;
- font-weight: bold;
- color: #3ea6ff;
- user-select: none;
- }
- .tanuki-timestamp-item span:nth-child(2) {
- flex: 1;
- margin-right: 12px;
- cursor: pointer;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #ddd;
- user-select: none;
- }
- .tanuki-timestamp-item .tanuki-note-placeholder {
- color: #999;
- font-style: italic;
- }
- .tanuki-timestamp-item input {
- padding: 6px 8px;
- border: 1px solid #666;
- border-radius: 0;
- background: #222;
- color: #fff;
- font-size: 15px;
- font-family: inherit;
- box-sizing: border-box;
- }
- .tanuki-timestamp-item input:focus {
- outline: none;
- border-color: #3ea6ff;
- }
- .tanuki-timestamp-item input.time-input {
- width: 80px;
- text-align: right;
- font-weight: bold;
- color: #3ea6ff;
- }
- .tanuki-timestamp-item input.note-input {
- flex: 1;
- margin-right: 12px;
- }
- .tanuki-timestamp-item button {
- background: #555;
- border: 1px solid #777;
- padding: 4px 8px;
- border-radius: 0;
- cursor: pointer;
- color: #fff;
- margin-left: 6px;
- font-size: 16px;
- line-height: 1;
- transition: background 0.2s, border-color 0.2s;
- }
- .tanuki-timestamp-item button:hover {
- background: #666;
- border-color: #888;
- }
- .tanuki-timestamp-item button.delete-btn {
- background: #d9534f;
- border-color: #d43f3a;
- }
- .tanuki-timestamp-item button.delete-btn:hover {
- background: #c9302c;
- border-color: #ac2925;
- }
- .tanuki-timestamp-item button.go-btn {
- background: #5cb85c;
- border-color: #4cae4c;
- }
- .tanuki-timestamp-item button.go-btn:hover {
- background: #449d44;
- border-color: #398439;
- }
- .tanuki-manager-footer {
- display: flex;
- justify-content: flex-end;
- flex-shrink: 0;
- padding-top: 10px;
- border-top: 1px solid #555;
- }
- .tanuki-manager button.delete-all-btn {
- background: #c9302c;
- border: 1px solid #ac2925;
- color: #fff;
- padding: 6px 12px;
- border-radius: 0;
- cursor: pointer;
- font-size: 13px;
- font-weight: bold;
- transition: background 0.2s, border-color 0.2s;
- }
- .tanuki-manager button.delete-all-btn:hover {
- background: #ac2925;
- border-color: #761c19;
- }
- .tanuki-manager button.delete-all-btn:disabled {
- background: #777;
- border-color: #999;
- color: #ccc;
- cursor: not-allowed;
- }
- .tanuki-empty-msg {
- color: #999;
- text-align: center;
- padding: 20px;
- font-style: italic;
- font-size: 14px;
- }
- .tanuki-notification {
- position: fixed;
- background: rgba(20, 20, 20, 0.9);
- color: #fff;
- padding: 12px 20px;
- border-radius: 0;
- font-size: 14px;
- transition: opacity 0.4s ease-out, transform 0.4s ease-out;
- z-index: 100001;
- pointer-events: none;
- box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
- border: 1px solid #444;
- opacity: 0;
- transform: translate(-50%, -50%) scale(0.9);
- }
- .tanuki-notification.show {
- opacity: 1;
- transform: translate(-50%, -50%) scale(1);
- }
- .tanuki-confirmation {
- position: fixed;
- background: rgba(30, 30, 30, 0.95);
- color: #eee;
- padding: 25px;
- border-radius: 0;
- z-index: 100000;
- min-width: 320px;
- text-align: center;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
- border: 1px solid #555;
- }
- .tanuki-confirmation div.tanuki-confirmation-message {
- margin-bottom: 18px;
- font-size: 15px;
- line-height: 1.4;
- }
- .tanuki-confirmation button {
- border: none;
- padding: 10px 20px;
- border-radius: 0;
- cursor: pointer;
- color: #fff;
- font-weight: bold;
- font-size: 14px;
- transition: background 0.2s, transform 0.1s;
- margin: 0 5px;
- }
- .tanuki-confirmation button:hover {
- transform: translateY(-1px);
- }
- .tanuki-confirmation button:active {
- transform: translateY(0px);
- }
- .tanuki-confirmation button.confirm-btn {
- background: #d9534f;
- }
- .tanuki-confirmation button.confirm-btn:hover {
- background: #c9302c;
- }
- .tanuki-confirmation button.cancel-btn {
- background: #555;
- }
- .tanuki-confirmation button.cancel-btn:hover {
- background: #666;
- }
- `;
- document.head.appendChild(style);
-
- // --- Database Functions ---
- async function openDatabase() {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, DB_VERSION);
- request.onupgradeneeded = (event) => {
- const db = event.target.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.createObjectStore(STORE_NAME, { keyPath: ['videoId', 'time'] });
- }
- };
- request.onsuccess = (event) => {
- resolve(event.target.result);
- };
- request.onerror = (event) => {
- console.error(`Tanuki Timestamp DB Error: ${event.target.error}`);
- reject(`Database error: ${event.target.error}`);
- };
- });
- }
-
- async function getTimestamps(videoId) {
- try {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const transaction = db.transaction(STORE_NAME, 'readonly');
- const store = transaction.objectStore(STORE_NAME);
- const request = store.getAll();
- request.onsuccess = (event) => {
- resolve(event.target.result
- .filter(t => t.videoId === videoId)
- .sort((a, b) => a.time - b.time));
- };
- request.onerror = (event) => {
- console.error(`Tanuki Timestamp DB Get Error: ${event.target.error}`);
- reject(event.target.error);
- };
- });
- } catch (error) {
- console.error('Tanuki Timestamp Error loading timestamps:', error);
- return [];
- }
- }
-
- async function saveTimestamp(videoId, time, note) {
- try {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const transaction = db.transaction(STORE_NAME, 'readwrite');
- const store = transaction.objectStore(STORE_NAME);
- const request = store.put({ videoId, time, note });
- request.onsuccess = () => {
- resolve();
- };
- request.onerror = (event) => {
- console.error(`Tanuki Timestamp DB Save Error: ${event.target.error}`);
- reject(event.target.error);
- };
- });
- } catch (error) {
- console.error('Tanuki Timestamp Error saving timestamp:', error);
- }
- }
-
- async function deleteTimestamp(videoId, time) {
- try {
- const db = await openDatabase();
- return new Promise((resolve, reject) => {
- const transaction = db.transaction(STORE_NAME, 'readwrite');
- const store = transaction.objectStore(STORE_NAME);
- const request = store.delete([videoId, time]);
- request.onsuccess = () => {
- resolve();
- };
- request.onerror = (event) => {
- console.error(`Tanuki Timestamp DB Delete Error: ${event.target.error}`);
- reject(event.target.error);
- };
- });
- } catch (error) {
- console.error('Tanuki Timestamp Error deleting timestamp:', error);
- }
- }
-
- // --- Utility Functions ---
- function getCurrentVideoId() {
- const currentUrl = window.location;
- const urlParams = new URLSearchParams(currentUrl.search);
- const videoIdFromParam = urlParams.get('v');
- if (videoIdFromParam && videoIdFromParam.length > 0) {
- return videoIdFromParam;
- }
-
- const pathname = currentUrl.pathname;
- const pathParts = pathname.split('/').filter(part => part !== '');
- if (pathParts.length === 2) {
- const potentialId = pathParts[1];
- const recognizedPathTypes = ['live', 'embed', 'shorts'];
- if (recognizedPathTypes.includes(pathParts[0]) && potentialId.length > 0) {
- return potentialId;
- }
- }
- return null;
- }
-
- function formatTime(seconds) {
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = Math.floor(seconds % 60);
- return [h, m, s].map(n => n.toString().padStart(2, '0')).join(':');
- }
-
- function parseTime(timeString) {
- const parts = timeString.split(':').map(Number);
- if (parts.some(isNaN) || parts.length < 2 || parts.length > 3) {
- return null;
- }
- while (parts.length < 3) {
- parts.unshift(0);
- }
- const [h, m, s] = parts;
- if (h < 0 || m < 0 || m > 59 || s < 0 || s > 59) {
- return null;
- }
- return h * 3600 + m * 60 + s;
- }
-
- function isLiveStream() {
- const timeDisplay = document.querySelector('.ytp-time-display');
- return timeDisplay && (timeDisplay.classList.contains('ytp-live') || timeDisplay.classList.contains('ytp-live-badge'));
- }
-
- // --- UI Notification & Confirmation ---
- function showNotification(message) {
- const existingToast = document.querySelector('.tanuki-notification');
- if (existingToast) {
- existingToast.remove();
- }
-
- const toast = document.createElement('div');
- toast.className = 'tanuki-notification';
- toast.textContent = message;
- document.body.appendChild(toast);
-
- const video = document.querySelector('video');
- if (video) {
- const videoRect = video.getBoundingClientRect();
- toast.style.left = `${videoRect.left + videoRect.width / 2}px`;
- toast.style.top = `${videoRect.top + 50}px`;
- toast.style.transform = 'translateX(-50%) scale(0.9)';
- } else {
- toast.style.left = '50%';
- toast.style.top = '10%';
- toast.style.transform = 'translateX(-50%) scale(0.9)';
- }
-
- requestAnimationFrame(() => {
- toast.classList.add('show');
- });
-
- setTimeout(() => {
- toast.style.opacity = '0';
- toast.style.transform = toast.style.transform.replace('scale(1)', 'scale(0.9)');
- setTimeout(() => {
- if (toast.parentNode) {
- toast.remove();
- }
- }, 400);
- }, 2500);
- }
-
- function showConfirmation(message) {
- return new Promise(resolve => {
- const existingModal = document.querySelector('.tanuki-confirmation');
- if (existingModal) {
- existingModal.remove();
- }
-
- const modal = document.createElement('div');
- modal.className = 'tanuki-confirmation';
- modal.addEventListener('click', e => e.stopPropagation());
-
- const video = document.querySelector('video');
- if (video) {
- const videoRect = video.getBoundingClientRect();
- modal.style.left = `${videoRect.left + videoRect.width / 2}px`;
- modal.style.top = `${videoRect.top + videoRect.height / 2}px`;
- modal.style.transform = 'translate(-50%, -50%)';
- } else {
- modal.style.position = 'fixed';
- modal.style.top = '50%';
- modal.style.left = '50%';
- modal.style.transform = 'translate(-50%, -50%)';
- }
-
- const messageEl = document.createElement('div');
- messageEl.textContent = message;
- messageEl.className = 'tanuki-confirmation-message';
-
- const buttonContainer = document.createElement('div');
-
- const confirmBtn = document.createElement('button');
- confirmBtn.textContent = 'Confirm';
- confirmBtn.className = 'confirm-btn';
- confirmBtn.addEventListener('click', () => {
- resolve(true);
- cleanup();
- });
-
- const cancelBtn = document.createElement('button');
- cancelBtn.textContent = 'Cancel';
- cancelBtn.className = 'cancel-btn';
- cancelBtn.addEventListener('click', () => {
- resolve(false);
- cleanup();
- });
-
- buttonContainer.append(confirmBtn, cancelBtn);
- modal.append(messageEl, buttonContainer);
- document.body.appendChild(modal);
-
- let timeoutId = null;
-
- const cleanup = () => {
- if (modal.parentNode) {
- document.body.removeChild(modal);
- }
- document.removeEventListener('click', outsideClickForConfirm, true);
- document.removeEventListener('keydown', keyHandlerForConfirm);
- clearTimeout(timeoutId);
- };
-
- const outsideClickForConfirm = (e) => {
- if (!modal.contains(e.target)) {
- resolve(false);
- cleanup();
- }
- };
-
- const keyHandlerForConfirm = (e) => {
- if (e.key === 'Escape') {
- resolve(false);
- cleanup();
- }
- };
-
- timeoutId = setTimeout(() => {
- document.addEventListener('click', outsideClickForConfirm, true);
- document.addEventListener('keydown', keyHandlerForConfirm);
- confirmBtn.focus();
- }, 0);
- });
- }
-
- // --- UI Cleanup ---
- function cleanupUI() {
- if (manager) {
- closeManager();
- }
- if (noteInput && noteInput.parentNode) {
- noteInput.remove();
- noteInput = null;
- }
- if (uiContainer && uiContainer.parentNode) {
- uiContainer.remove();
- uiContainer = null;
- }
- removeProgressMarkers();
- if (currentTooltip && currentTooltip.parentNode) {
- currentTooltip.remove();
- currentTooltip = null;
- }
- const video = document.querySelector('video');
- if (updateMarkers && video) {
- video.removeEventListener('timeupdate', updateMarkers);
- updateMarkers = null;
- }
- if (timeUpdateInterval) {
- clearInterval(timeUpdateInterval);
- timeUpdateInterval = null;
- }
- }
-
- // --- Progress Bar Markers ---
- function removeProgressMarkers() {
- progressMarkers.forEach((marker) => {
- if (marker && marker.parentNode) {
- marker.remove();
- }
- });
- progressMarkers = [];
- }
-
- function updateMarker(oldTime, newTime, newNote) {
- const marker = progressMarkers.find(m => parseInt(m.dataset.time) === oldTime);
- if (!marker) {
- return;
- }
-
- marker.dataset.time = newTime;
- marker.dataset.note = newNote || '';
- marker.title = formatTime(newTime) + (newNote ? ` - ${newNote}` : '');
-
- const video = document.querySelector('video');
- const progressBar = document.querySelector('.ytp-progress-bar');
- if (!video || !progressBar) {
- return;
- }
-
- const isLive = isLiveStream();
- const duration = isLive ? video.currentTime : video.duration;
- if (!duration || isNaN(duration) || duration <= 0) {
- return;
- }
-
- const position = Math.min(100, Math.max(0, (newTime / duration) * 100));
- marker.style.left = `${position}%`;
- }
-
- function removeMarker(time) {
- const index = progressMarkers.findIndex(m => parseInt(m.dataset.time) === time);
- if (index !== -1) {
- const markerToRemove = progressMarkers[index];
- if (markerToRemove && markerToRemove.parentNode) {
- markerToRemove.remove();
- }
- progressMarkers.splice(index, 1);
- }
- }
-
- async function createProgressMarkers() {
- removeProgressMarkers();
- const video = document.querySelector('video');
- const progressBar = document.querySelector('.ytp-progress-bar');
- if (!video || !progressBar || !currentVideoId) {
- return;
- }
-
- const timestamps = await getTimestamps(currentVideoId);
- const isLive = isLiveStream();
- const duration = isLive ? video.currentTime : video.duration;
- if (!duration || isNaN(duration) || duration <= 0) {
- return;
- }
-
- timestamps.forEach(ts => {
- addProgressMarker(ts, duration);
- });
- }
-
- function addProgressMarker(ts, videoDuration = null) {
- const progressBar = document.querySelector('.ytp-progress-bar');
- if (!progressBar) {
- return;
- }
-
- let duration = videoDuration;
- if (duration === null) {
- const video = document.querySelector('video');
- if (!video) {
- return;
- }
- const isLive = isLiveStream();
- duration = isLive ? video.currentTime : video.duration;
- }
-
- if (!duration || isNaN(duration) || duration <= 0) {
- return;
- }
-
- const existingMarkerIndex = progressMarkers.findIndex(m => parseInt(m.dataset.time) === ts.time);
- if (existingMarkerIndex !== -1) {
- const existingMarker = progressMarkers[existingMarkerIndex];
- existingMarker.dataset.note = ts.note || '';
- existingMarker.title = formatTime(ts.time) + (ts.note ? ` - ${ts.note}` : '');
- const position = Math.min(100, Math.max(0, (ts.time / duration) * 100));
- existingMarker.style.left = `${position}%`;
- return;
- }
-
- const marker = document.createElement('div');
- marker.className = 'tanuki-progress-marker';
- const position = Math.min(100, Math.max(0, (ts.time / duration) * 100));
- marker.style.left = `${position}%`;
- marker.dataset.time = ts.time;
- marker.dataset.note = ts.note || '';
- marker.title = formatTime(ts.time) + (ts.note ? ` - ${ts.note}` : '');
- marker.addEventListener('mouseenter', showMarkerTooltip);
- marker.addEventListener('mouseleave', hideMarkerTooltip);
- marker.addEventListener('click', (e) => {
- e.stopPropagation();
- const video = document.querySelector('video');
- if (video) {
- video.currentTime = ts.time;
- }
- });
- progressBar.appendChild(marker);
- progressMarkers.push(marker);
- }
-
- function showMarkerTooltip(e) {
- if (currentTooltip) {
- currentTooltip.remove();
- }
-
- const marker = e.target;
- const note = marker.dataset.note;
- const time = parseInt(marker.dataset.time);
- const formattedTime = formatTime(time);
- const tooltipText = note ? `${formattedTime} - ${note}` : formattedTime;
-
- currentTooltip = document.createElement('div');
- currentTooltip.className = 'tanuki-tooltip';
- currentTooltip.textContent = tooltipText;
-
- const rect = marker.getBoundingClientRect();
- currentTooltip.style.left = `${rect.left + rect.width / 2}px`;
- currentTooltip.style.top = `${rect.top}px`;
-
- document.body.appendChild(currentTooltip);
- }
-
- function hideMarkerTooltip() {
- if (currentTooltip) {
- currentTooltip.remove();
- currentTooltip = null;
- }
- }
-
- // --- Main UI Setup ---
- function setupUI() {
- if (uiContainer) {
- return;
- }
-
- const controls = document.querySelector('.ytp-left-controls');
- const video = document.querySelector('video');
- if (!controls || !video || !video.duration || video.duration <= 0) {
- return;
- }
-
- uiContainer = document.createElement('span');
- uiContainer.className = 'tanuki-ui-container';
-
- const timestampEl = document.createElement('span');
- timestampEl.className = 'tanuki-timestamp';
- timestampEl.textContent = formatTime(video.currentTime); // Initial time
- timestampEl.title = 'Click to copy current time';
- timestampEl.addEventListener('click', async () => {
- const video = document.querySelector('video');
- if (video) {
- const time = Math.floor(video.currentTime);
- try {
- await navigator.clipboard.writeText(formatTime(time));
- showNotification('Current timestamp copied!');
- } catch (error) {
- showNotification('Copy failed');
- console.error("Tanuki Timestamp Copy Error:", error);
- }
- }
- });
-
- const createButton = (label, title, handler) => {
- const btn = document.createElement('button');
- btn.className = 'tanuki-button';
- btn.textContent = label;
- btn.title = title;
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- handler();
- });
- return btn;
- };
-
- const addButton = createButton('+', 'Add timestamp at current time', async () => {
- const video = document.querySelector('video');
- if (video && currentVideoId) {
- const time = Math.floor(video.currentTime);
- showNoteInput(video, time);
- }
- });
-
- const removeButton = createButton('-', 'Remove nearest timestamp', async () => {
- const video = document.querySelector('video');
- if (video && currentVideoId) {
- const currentTime = Math.floor(video.currentTime);
- const timestamps = await getTimestamps(currentVideoId);
- if (!timestamps.length) {
- showNotification('No timestamps to remove');
- return;
- }
- const closest = timestamps.reduce((prev, curr) =>
- Math.abs(curr.time - currentTime) < Math.abs(prev.time - currentTime) ? curr : prev
- );
-
- const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(closest.time)}?`);
- if (confirmed) {
- await deleteTimestamp(currentVideoId, closest.time);
- removeMarker(closest.time);
- if (manager) {
- const itemToRemove = manager.querySelector(`.tanuki-timestamp-item[data-time="${closest.time}"]`);
- if (itemToRemove) {
- itemToRemove.remove();
- }
- checkManagerEmpty();
- }
- showNotification(`Removed ${formatTime(closest.time)}`);
- }
- }
- });
-
- const copyButton = createButton('C', 'Copy all timestamps', async () => {
- if (!currentVideoId) {
- return;
- }
- const timestamps = await getTimestamps(currentVideoId);
- if (!timestamps.length) {
- showNotification('No timestamps to copy');
- return;
- }
- const formatted = timestamps
- .map(t => `${formatTime(t.time)}${t.note ? ` ${t.note}` : ''}`)
- .join('\n');
- try {
- await navigator.clipboard.writeText(formatted);
- showNotification('Copied all timestamps!');
- } catch (error) {
- showNotification('Copy failed');
- console.error("Tanuki Timestamp Copy All Error:", error);
- }
- });
-
- const manageButton = createButton('M', 'Manage timestamps', () => {
- showManager();
- });
-
- uiContainer.appendChild(timestampEl);
- uiContainer.appendChild(addButton);
- uiContainer.appendChild(removeButton);
- uiContainer.appendChild(copyButton);
- uiContainer.appendChild(manageButton);
-
- // Append to the end of left controls (generally safer for compatibility)
- if (controls && uiContainer) {
- controls.appendChild(uiContainer);
- }
-
- // Update timestamp display periodically
- if (timeUpdateInterval) {
- clearInterval(timeUpdateInterval); // Clear previous if any
- }
- timeUpdateInterval = setInterval(() => {
- const video = document.querySelector('video');
- const currentTsEl = uiContainer?.querySelector('.tanuki-timestamp');
- if (video && currentTsEl) {
- currentTsEl.textContent = formatTime(video.currentTime);
- } else if (!currentTsEl && timeUpdateInterval) {
- clearInterval(timeUpdateInterval);
- timeUpdateInterval = null;
- }
- }, 1000);
-
- createProgressMarkers();
-
- // Handle live stream marker updates
- if (isLiveStream() && video) {
- updateMarkers = () => {
- const video = document.querySelector('video'); // Re-select video in case element changed
- if (!video) {
- return;
- }
- const currentTime = video.currentTime;
- if (!currentTime || currentTime <= 0) {
- return;
- }
- progressMarkers.forEach(marker => {
- const time = parseInt(marker.dataset.time);
- if (time <= currentTime) {
- const position = Math.min(100, Math.max(0, (time / currentTime) * 100));
- marker.style.left = `${position}%`;
- } else {
- marker.style.left = '100%';
- }
- });
- };
- video.addEventListener('timeupdate', updateMarkers);
- }
- }
-
- // --- Note Input Popup ---
- function showNoteInput(video, time, initialNote = '') {
- if (noteInput) {
- return;
- }
-
- noteInput = document.createElement('div');
- noteInput.className = 'tanuki-note-input';
- noteInput.addEventListener('click', e => e.stopPropagation());
-
- const input = document.createElement('input');
- input.type = 'text';
- input.placeholder = 'Enter note (optional)';
- input.value = initialNote;
-
- const saveBtn = document.createElement('button');
- saveBtn.textContent = 'Save';
-
- noteInput.append(input, saveBtn);
- document.body.appendChild(noteInput);
-
- const videoRect = video.getBoundingClientRect();
- noteInput.style.left = `${videoRect.left + videoRect.width / 2}px`;
- noteInput.style.top = `${videoRect.top + videoRect.height / 2}px`;
- noteInput.style.transform = 'translate(-50%, -50%)';
-
- setTimeout(() => {
- input.focus();
- input.setSelectionRange(input.value.length, input.value.length);
- }, 50);
-
- let timeoutId = null;
-
- const cleanup = () => {
- if (noteInput && noteInput.parentNode) {
- noteInput.remove();
- }
- noteInput = null;
- document.removeEventListener('click', outsideClick, true);
- document.removeEventListener('keydown', handleEscape);
- clearTimeout(timeoutId);
- };
-
- const saveHandler = async () => {
- const note = input.value.trim();
- const ts = { videoId: currentVideoId, time, note };
-
- if (!initialNote) {
- const existingTimestamps = await getTimestamps(currentVideoId);
- if (existingTimestamps.some(t => t.time === time)) {
- const confirmed = await showConfirmation(`Timestamp at ${formatTime(time)} already exists. Overwrite note?`);
- if (!confirmed) {
- cleanup();
- return;
- }
- }
- }
-
- await saveTimestamp(currentVideoId, time, note);
- addProgressMarker(ts);
-
- if (manager) {
- const list = manager.querySelector('.tanuki-manager-list');
- const existingItem = list?.querySelector(`.tanuki-timestamp-item[data-time="${time}"]`);
- if (existingItem) {
- updateTimestampItem(existingItem, ts);
- } else if (list) {
- const newItem = createTimestampItem(ts);
- const timestamps = await getTimestamps(currentVideoId);
- let inserted = false;
- const items = list.querySelectorAll('.tanuki-timestamp-item');
- for (let i = 0; i < items.length; i++) {
- const itemTime = parseInt(items[i].dataset.time);
- if (time < itemTime) {
- list.insertBefore(newItem, items[i]);
- inserted = true;
- break;
- }
- }
- if (!inserted) {
- list.appendChild(newItem);
- }
- checkManagerEmpty(false);
- }
- }
- cleanup();
- showNotification(`Saved ${formatTime(time)}${note ? ` - "${note}"` : ''}`);
- };
-
- const outsideClick = (e) => {
- if (noteInput && !noteInput.contains(e.target)) {
- cleanup();
- }
- };
-
- const handleEscape = (e) => {
- if (e.key === 'Escape') {
- cleanup();
- }
- };
-
- saveBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- saveHandler();
- });
- input.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- saveHandler();
- }
- });
-
- timeoutId = setTimeout(() => {
- document.addEventListener('click', outsideClick, true);
- document.addEventListener('keydown', handleEscape);
- }, 0);
- }
-
- // --- Timestamp Manager Popup ---
- async function showManager() {
- if (!currentVideoId || manager) {
- return;
- }
-
- manager = document.createElement('div');
- manager.className = 'tanuki-manager';
- manager.addEventListener('click', e => e.stopPropagation());
-
- const header = document.createElement('div');
- header.className = 'tanuki-manager-header';
-
- const title = document.createElement('h3');
- title.textContent = 'Timestamp Manager';
-
- const closeButton = document.createElement('button');
- closeButton.textContent = '✕';
- closeButton.title = 'Close Manager (Esc)';
- closeButton.className = 'close-btn';
- closeButton.addEventListener('click', closeManager);
-
- header.append(title, closeButton);
- manager.appendChild(header);
-
- const list = document.createElement('div');
- list.className = 'tanuki-manager-list';
- manager.appendChild(list);
-
- const footer = document.createElement('div');
- footer.className = 'tanuki-manager-footer';
-
- const deleteAllBtn = document.createElement('button');
- deleteAllBtn.textContent = 'Delete All Timestamps';
- deleteAllBtn.title = 'Delete all timestamps for this video';
- deleteAllBtn.className = 'delete-all-btn';
- deleteAllBtn.addEventListener('click', handleDeleteAll);
- footer.appendChild(deleteAllBtn);
- manager.appendChild(footer);
-
- const timestamps = await getTimestamps(currentVideoId);
- if (!timestamps.length) {
- checkManagerEmpty(true, list);
- deleteAllBtn.disabled = true;
- } else {
- timestamps.forEach(ts => {
- const item = createTimestampItem(ts);
- list.appendChild(item);
- });
- deleteAllBtn.disabled = false;
- }
-
- positionManager();
- document.body.appendChild(manager);
-
- setTimeout(() => {
- document.addEventListener('keydown', managerKeydownHandler);
- document.addEventListener('click', managerOutsideClickHandler, true);
- }, 0);
- }
-
- // --- Manager Helper Functions ---
-
- function closeManager() {
- if (manager) {
- if (manager.parentNode) {
- manager.remove();
- }
- manager = null;
- document.removeEventListener('keydown', managerKeydownHandler);
- document.removeEventListener('click', managerOutsideClickHandler, true);
- }
- }
-
- function managerKeydownHandler(e) {
- if (e.key === 'Escape') {
- const activeElement = document.activeElement;
- const isInputFocused = manager && manager.contains(activeElement) && activeElement.tagName === 'INPUT';
- if (!isInputFocused) {
- closeManager();
- } else {
- activeElement.blur();
- }
- }
- }
-
- function managerOutsideClickHandler(e) {
- const isInsideConfirmation = !!e.target.closest('.tanuki-confirmation');
- const isManageButton = !!e.target.closest('.tanuki-button[title="Manage timestamps"]');
- if (manager && !manager.contains(e.target) && !isManageButton && !isInsideConfirmation) {
- closeManager();
- }
- }
-
- function positionManager() {
- if (!manager) {
- return;
- }
- const video = document.querySelector('video');
- if (video) {
- const videoRect = video.getBoundingClientRect();
- const managerWidth = 540;
- const managerHeight = 380;
- let left = videoRect.left + (videoRect.width - managerWidth) / 2;
- let top = videoRect.top + (videoRect.height - managerHeight) / 2;
-
- left = Math.max(10, Math.min(window.innerWidth - managerWidth - 10, left));
- top = Math.max(10, Math.min(window.innerHeight - managerHeight - 10, top));
-
- manager.style.left = `${left}px`;
- manager.style.top = `${top}px`;
- manager.style.transform = '';
- } else {
- manager.style.position = 'fixed';
- manager.style.top = '50%';
- manager.style.left = '50%';
- manager.style.transform = 'translate(-50%, -50%)';
- }
- }
-
- function checkManagerEmpty(forceShow = null, list = null) {
- if (!manager && !list) {
- return;
- }
-
- const theList = list || manager?.querySelector('.tanuki-manager-list');
- const deleteAllBtn = manager?.querySelector('.delete-all-btn');
-
- if (!theList) {
- return;
- }
-
- const emptyMsgClass = 'tanuki-empty-msg';
- let emptyMsg = theList.querySelector(`.${emptyMsgClass}`);
- const hasItems = !!theList.querySelector('.tanuki-timestamp-item');
-
- if (forceShow === true || (forceShow === null && !hasItems)) {
- if (!emptyMsg) {
- emptyMsg = document.createElement('div');
- emptyMsg.className = emptyMsgClass;
- emptyMsg.textContent = 'No timestamps created for this video yet.';
- theList.prepend(emptyMsg);
- }
- if (deleteAllBtn) {
- deleteAllBtn.disabled = true;
- }
- } else if (forceShow === false || (forceShow === null && hasItems)) {
- if (emptyMsg) {
- emptyMsg.remove();
- }
- if (deleteAllBtn) {
- deleteAllBtn.disabled = false;
- }
- }
- }
-
- async function handleDeleteAll() {
- if (!currentVideoId || !manager) {
- return;
- }
-
- const listElement = manager.querySelector('.tanuki-manager-list');
- const deleteAllButton = manager.querySelector('.delete-all-btn');
- if (!listElement) {
- console.error("Tanuki Timestamp: Manager list element not found in handleDeleteAll.");
- return;
- }
-
- const timestamps = await getTimestamps(currentVideoId);
- if (timestamps.length === 0) {
- showNotification("No timestamps to delete.");
- return;
- }
-
- const confirmed = await showConfirmation(`Are you sure you want to delete all ${timestamps.length} timestamps for this video? This cannot be undone.`);
-
- if (!manager || !manager.contains(listElement)) {
- return; // Manager closed or list is stale after confirmation
- }
-
- if (confirmed) {
- try {
- const deletePromises = timestamps.map(ts => deleteTimestamp(currentVideoId, ts.time));
- await Promise.all(deletePromises);
-
- while (listElement.firstChild) {
- listElement.removeChild(listElement.firstChild);
- }
-
- removeProgressMarkers();
- checkManagerEmpty(true, listElement);
- showNotification("All timestamps deleted successfully.");
-
- } catch (error) {
- console.error("Tanuki Timestamp: Error deleting all timestamps:", error);
- showNotification("Error occurred while deleting timestamps.");
- if (deleteAllButton) {
- deleteAllButton.disabled = timestamps.length === 0;
- }
- }
- }
- }
-
- // --- Manager Timestamp Item Creation & Editing ---
- function createTimestampItem(ts) {
- const item = document.createElement('div');
- item.className = 'tanuki-timestamp-item';
- item.dataset.time = ts.time;
-
- const timeEl = document.createElement('span');
- timeEl.textContent = formatTime(ts.time);
- timeEl.title = 'Double-click to edit time';
-
- const noteEl = document.createElement('span');
- if (ts.note) {
- noteEl.textContent = ts.note;
- } else {
- noteEl.textContent = NOTE_PLACEHOLDER;
- noteEl.classList.add('tanuki-note-placeholder');
- }
- noteEl.title = 'Double-click to edit note';
-
- const goBtn = document.createElement('button');
- goBtn.textContent = '▶';
- goBtn.title = 'Go to timestamp';
- goBtn.className = 'go-btn';
-
- const deleteBtn = document.createElement('button');
- deleteBtn.textContent = '✕';
- deleteBtn.title = 'Delete timestamp';
- deleteBtn.className = 'delete-btn';
-
- const buttonContainer = document.createElement('div');
- buttonContainer.style.display = 'flex';
- buttonContainer.style.alignItems = 'center';
- buttonContainer.append(goBtn, deleteBtn);
-
- item.append(timeEl, noteEl, buttonContainer);
-
- goBtn.addEventListener('click', () => {
- const video = document.querySelector('video');
- if (video) {
- video.currentTime = ts.time;
- }
- });
-
- deleteBtn.addEventListener('click', async () => {
- const currentItemTime = parseInt(item.dataset.time);
- const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(currentItemTime)}?`);
- if (confirmed) {
- await deleteTimestamp(currentVideoId, currentItemTime);
- removeMarker(currentItemTime);
- item.remove();
- checkManagerEmpty();
- }
- });
-
- const makeEditable = (element, inputClass, originalValue, saveCallback, validationCallback = null) => {
- if (element.parentNode && element.parentNode.querySelector('input')) {
- return;
- }
-
- const input = document.createElement('input');
- input.type = 'text';
- input.className = inputClass;
- input.value = originalValue;
-
- const originalElement = element;
- originalElement.replaceWith(input);
- input.focus();
- input.select();
-
- let isSaving = false;
-
- const saveChanges = async () => {
- if (!input.parentNode) {
- return false;
- }
- if (isSaving) {
- return false;
- }
- isSaving = true;
-
- const newValue = input.value.trim();
-
- if (validationCallback && !(await validationCallback(newValue))) {
- input.replaceWith(originalElement);
- isSaving = false;
- return false;
- }
-
- const originalTimeSeconds = (inputClass === 'time-input') ? parseTime(originalElement.textContent) : null;
- const hasChanged = (inputClass === 'time-input')
- ? parseTime(newValue) !== originalTimeSeconds
- : newValue !== (ts.note || '');
-
- if (hasChanged) {
- try {
- await saveCallback(newValue, input, originalElement);
- } catch (error) {
- console.error("Tanuki Timestamp: Error during save callback:", error);
- if (input.parentNode) {
- input.replaceWith(originalElement);
- }
- }
- } else {
- if (input.parentNode) {
- input.replaceWith(originalElement);
- }
- }
- isSaving = false;
- return true;
- };
-
- const handleBlur = async (e) => {
- const relatedTarget = e.relatedTarget;
- if (!relatedTarget || !item.contains(relatedTarget) || !['BUTTON', 'INPUT', 'A'].includes(relatedTarget.tagName)) {
- await saveChanges();
- }
- };
-
- input.addEventListener('blur', (e) => setTimeout(() => handleBlur(e), 150));
-
- input.addEventListener('keydown', async (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- await saveChanges();
- } else if (e.key === 'Escape') {
- e.preventDefault();
- if (input.parentNode) {
- input.replaceWith(originalElement);
- }
- }
- });
- };
-
- timeEl.addEventListener('dblclick', () => {
- makeEditable(timeEl, 'time-input', timeEl.textContent,
- async (newTimeString, inputElement, originalDisplayElement) => {
- const newTime = parseTime(newTimeString);
- const oldTime = ts.time;
-
- await deleteTimestamp(currentVideoId, oldTime);
- await saveTimestamp(currentVideoId, newTime, ts.note);
-
- ts.time = newTime;
- item.dataset.time = newTime;
- originalDisplayElement.textContent = formatTime(newTime);
- if (inputElement.parentNode) {
- inputElement.replaceWith(originalDisplayElement);
- }
- updateMarker(oldTime, newTime, ts.note);
-
- const list = manager?.querySelector('.tanuki-manager-list');
- if(list) {
- const items = Array.from(list.querySelectorAll('.tanuki-timestamp-item'));
- items.sort((a, b) => parseInt(a.dataset.time) - parseInt(b.dataset.time));
- items.forEach(sortedItem => list.appendChild(sortedItem));
- }
- showNotification(`Time updated to ${formatTime(newTime)}`);
- },
- async (newTimeString) => {
- const newTime = parseTime(newTimeString);
- if (newTime === null || newTime < 0) {
- showNotification('Invalid time format (HH:MM:SS)');
- return false;
- }
- if (newTime !== ts.time) {
- const existingTimestamps = await getTimestamps(currentVideoId);
- if (existingTimestamps.some(t => t.time === newTime)) {
- showNotification(`Timestamp at ${formatTime(newTime)} already exists.`);
- return false;
- }
- }
- return true;
- }
- );
- });
-
- noteEl.addEventListener('dblclick', () => {
- makeEditable(noteEl, 'note-input', ts.note || '',
- async (newNote, inputElement, originalDisplayElement) => {
- await saveTimestamp(currentVideoId, ts.time, newNote);
-
- ts.note = newNote;
- if (newNote) {
- originalDisplayElement.textContent = newNote;
- originalDisplayElement.classList.remove('tanuki-note-placeholder');
- } else {
- originalDisplayElement.textContent = NOTE_PLACEHOLDER;
- originalDisplayElement.classList.add('tanuki-note-placeholder');
- }
- if (inputElement.parentNode) {
- inputElement.replaceWith(originalDisplayElement);
- }
- updateMarker(ts.time, ts.time, newNote);
- showNotification(`Note updated for ${formatTime(ts.time)}`);
- }
- );
- });
-
- return item;
- }
-
- function updateTimestampItem(itemElement, ts) {
- if (!itemElement) {
- return;
- }
-
- const timeEl = itemElement.querySelector('span:first-child');
- const noteEl = itemElement.querySelector('span:nth-child(2)');
-
- if (timeEl) {
- timeEl.textContent = formatTime(ts.time);
- }
- if (noteEl) {
- if (ts.note) {
- noteEl.textContent = ts.note;
- noteEl.classList.remove('tanuki-note-placeholder');
- } else {
- noteEl.textContent = NOTE_PLACEHOLDER;
- noteEl.classList.add('tanuki-note-placeholder');
- }
- }
- itemElement.dataset.time = ts.time;
- }
-
- // --- Initialization and Video Change Detection ---
- let initInterval = setInterval(() => {
- const videoId = getCurrentVideoId();
- const videoPlayer = document.querySelector('video');
- const controlsExist = !!document.querySelector('.ytp-left-controls');
-
- if (videoId && videoPlayer && videoPlayer.readyState >= 1 && controlsExist) {
- if (videoId !== currentVideoId) {
- cleanupUI();
- currentVideoId = videoId;
- setTimeout(setupUI, 500);
- } else if (!uiContainer && currentVideoId === videoId) {
- cleanupUI();
- setTimeout(setupUI, 500);
- }
- } else if (currentVideoId && (!videoId || !videoPlayer || !controlsExist)) {
- cleanupUI();
- currentVideoId = null;
- }
- }, 1000);
-
- })();