您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Full-featured timestamp manager with video tracking, inline editing, seeking, and sharp UI.
当前为
// ==UserScript== // @name YouTube Timestamp Manager // @namespace http://tampermonkey.net/ // @version 2.3 // @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; // Inject CSS into <head> 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) => 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) => reject(event.target.error); }); } catch (error) { console.error('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) => reject(event.target.error); }); } catch (error) { console.error('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) => reject(event.target.error); }); } catch (error) { console.error('Error deleting timestamp:', error); } } // --- Utility Functions --- function getCurrentVideoId() { const urlParams = new URLSearchParams(window.location.search); return urlParams.get('v'); } 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; // Invalid format } while (parts.length < 3) { parts.unshift(0); // Pad with hours/minutes if missing } const [h, m, s] = parts; if (h < 0 || m < 0 || m > 59 || s < 0 || s > 59) { return null; // Invalid values } return h * 3600 + m * 60 + s; } function isLiveStream() { const timeDisplay = document.querySelector('.ytp-time-display'); return timeDisplay && timeDisplay.classList.contains('ytp-live'); } // --- UI Notification & Confirmation --- function showNotification(message) { // Remove existing toast if any 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'); // Center positioning (relative to viewport or video) if (video) { const videoRect = video.getBoundingClientRect(); // Position near top-center of video player toast.style.left = `${videoRect.left + videoRect.width / 2}px`; toast.style.top = `${videoRect.top + 50}px`; // Offset from top // Ensure transform origin is correct for centering toast.style.transform = 'translateX(-50%) scale(0.9)'; // Initial state for animation } else { // Fallback if video isn't found toast.style.left = '50%'; toast.style.top = '10%'; // Near top of viewport // Ensure transform origin is correct for centering toast.style.transform = 'translateX(-50%) scale(0.9)'; // Initial state for animation } // Trigger the animation requestAnimationFrame(() => { toast.classList.add('show'); // Add class to animate in }); // Auto-remove after delay setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = toast.style.transform.replace('scale(1)', 'scale(0.9)'); // Animate out setTimeout(() => toast.remove(), 400); // Remove after fade out animation }, 2500); // Increased display time slightly } function showConfirmation(message) { return new Promise(resolve => { // Remove existing confirmation if any const existingModal = document.querySelector('.tanuki-confirmation'); if (existingModal) existingModal.remove(); const modal = document.createElement('div'); modal.className = 'tanuki-confirmation'; // Stop clicks inside modal from propagating to manager's outside click listener modal.addEventListener('click', e => e.stopPropagation()); const video = document.querySelector('video'); // Center positioning (relative to viewport or 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%)'; // Center using transform } else { // Fallback positioning 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'; // Add class for styling const buttonContainer = document.createElement('div'); // Container for buttons const confirmBtn = document.createElement('button'); confirmBtn.textContent = 'Confirm'; confirmBtn.className = 'confirm-btn'; // Add class for styling confirmBtn.addEventListener('click', (e) => { // e.stopPropagation(); // Already stopped by modal listener resolve(true); cleanup(); }); const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; cancelBtn.className = 'cancel-btn'; // Add class for styling cancelBtn.addEventListener('click', (e) => { // e.stopPropagation(); // Already stopped by modal listener resolve(false); cleanup(); }); buttonContainer.append(confirmBtn, cancelBtn); // Add buttons to container modal.append(messageEl, buttonContainer); // Add message and button container document.body.appendChild(modal); let timeoutId = null; const cleanup = () => { if (modal.parentNode) { document.body.removeChild(modal); } // Remove the document-level listeners specific to this confirmation document.removeEventListener('click', outsideClickForConfirm, true); document.removeEventListener('keydown', keyHandlerForConfirm); clearTimeout(timeoutId); }; // Listener specifically for clicks outside *this confirmation modal* const outsideClickForConfirm = (e) => { // If the click is outside the modal, resolve false and cleanup if (!modal.contains(e.target)) { resolve(false); cleanup(); } }; // Listener specifically for keydowns while *this confirmation modal* is open const keyHandlerForConfirm = (e) => { if (e.key === 'Escape') { resolve(false); cleanup(); } else if (e.key === 'Enter') { // Optional: Confirm on Enter // resolve(true); // cleanup(); } }; // Use timeout to add listeners after current event cycle finishes // Add the specific listeners for this modal instance timeoutId = setTimeout(() => { document.addEventListener('click', outsideClickForConfirm, true); // Capture phase document.addEventListener('keydown', keyHandlerForConfirm); confirmBtn.focus(); // Focus the confirm button by default }, 0); }); } // --- UI Cleanup --- function cleanupUI() { if (manager) { closeManager(); // Use the dedicated close function which handles listeners } if (noteInput) { noteInput.remove(); noteInput = null; // Potentially remove noteInput specific listeners if added globally } if (uiContainer) { uiContainer.remove(); uiContainer = null; } removeProgressMarkers(); if (currentTooltip) { currentTooltip.remove(); currentTooltip = null; } const video = document.querySelector('video'); if (updateMarkers && video) { video.removeEventListener('timeupdate', updateMarkers); updateMarkers = null; } } // --- Progress Bar Markers --- function removeProgressMarkers() { progressMarkers.forEach((marker, index) => { try { if (marker && marker.parentNode) { // Check if marker exists and is in DOM marker.remove(); } } catch (e) { console.error(`Tanuki Timestamp: Error removing marker at index ${index}:`, e); } }); progressMarkers = []; // Clears the array reference } 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}` : ''); // Update title // Recalculate position 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; // Added duration > 0 check const position = Math.min(100, Math.max(0, (newTime / duration) * 100)); // Clamp between 0 and 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); // Remove from array regardless of DOM state } } async function createProgressMarkers() { removeProgressMarkers(); // Clear existing before adding new ones 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; // Added duration > 0 check timestamps.forEach(ts => { addProgressMarker(ts, duration); // Pass duration to avoid recalculating }); } 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; // Check duration validity // Check if marker already exists for this time *in the array* const existingMarkerIndex = progressMarkers.findIndex(m => parseInt(m.dataset.time) === ts.time); if (existingMarkerIndex !== -1) { // Update existing marker's note and ensure position is correct 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; } // Create and add new marker const marker = document.createElement('div'); marker.className = 'tanuki-progress-marker'; const position = Math.min(100, Math.max(0, (ts.time / duration) * 100)); // Clamp position marker.style.left = `${position}%`; marker.dataset.time = ts.time; marker.dataset.note = ts.note || ''; marker.title = formatTime(ts.time) + (ts.note ? ` - ${ts.note}` : ''); // Add title for hover info marker.addEventListener('mouseenter', showMarkerTooltip); marker.addEventListener('mouseleave', hideMarkerTooltip); marker.addEventListener('click', (e) => { // Seek on marker click e.stopPropagation(); // Prevent progress bar seek if user clicks marker directly const video = document.querySelector('video'); if (video) video.currentTime = ts.time; }); progressBar.appendChild(marker); progressMarkers.push(marker); // Add to array *after* adding to DOM } function showMarkerTooltip(e) { if (currentTooltip) currentTooltip.remove(); // Remove previous instantly 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(); // Position tooltip centered above the marker currentTooltip.style.left = `${rect.left + rect.width / 2}px`; currentTooltip.style.top = `${rect.top}px`; // Align top with marker top initially // transform will move it up 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'); // Ensure video has duration and controls exist 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 = '00:00:00'; 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'); } } }); 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(); // Prevent video pause/play 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; } // Find the timestamp closest to the current time const closest = timestamps.reduce((prev, curr) => Math.abs(curr.time - currentTime) < Math.abs(prev.time - currentTime) ? curr : prev ); // Show confirmation dialog const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(closest.time)}?`); if (confirmed) { await deleteTimestamp(currentVideoId, closest.time); removeMarker(closest.time); // Remove from progress bar // If manager is open, remove from list if (manager) { const itemToRemove = manager.querySelector(`.tanuki-timestamp-item[data-time="${closest.time}"]`); if (itemToRemove) itemToRemove.remove(); checkManagerEmpty(); // Check if list is now empty } 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'); navigator.clipboard.writeText(formatted) .then(() => showNotification('Copied all timestamps!')); }); const manageButton = createButton('M', 'Manage timestamps', () => showManager()); uiContainer.appendChild(timestampEl); uiContainer.appendChild(addButton); uiContainer.appendChild(removeButton); uiContainer.appendChild(copyButton); uiContainer.appendChild(manageButton); // Insert into controls, trying to place it after volume but before other buttons const volumePanel = controls.querySelector('.ytp-volume-panel'); if (volumePanel && volumePanel.nextSibling) { controls.insertBefore(uiContainer, volumePanel.nextSibling); } else { controls.appendChild(uiContainer); // Fallback: append at the end } // Update timestamp display const timeUpdateInterval = setInterval(() => { const video = document.querySelector('video'); const currentTsEl = uiContainer?.querySelector('.tanuki-timestamp'); // Check if still exists if (video && currentTsEl) { currentTsEl.textContent = formatTime(video.currentTime); } else if (!currentTsEl && timeUpdateInterval) { // Ensure interval exists before clearing clearInterval(timeUpdateInterval); // Stop interval if element is gone } }, 1000); createProgressMarkers(); // Handle live stream marker updates if (isLiveStream() && video) { updateMarkers = () => { const currentTime = video.currentTime; if (!currentTime || currentTime <= 0) return; // Ignore if time is invalid progressMarkers.forEach(marker => { const time = parseInt(marker.dataset.time); if (time <= currentTime) { const position = Math.min(100, Math.max(0, (time / currentTime) * 100)); // Clamp marker.style.left = `${position}%`; } else { // For live streams, future markers might not be relevant or position is uncertain marker.style.left = '100%'; // Or hide them: marker.style.display = 'none'; } }); }; video.addEventListener('timeupdate', updateMarkers); } } // --- Note Input Popup --- function showNoteInput(video, time, initialNote = '') { if (noteInput) return; // Prevent multiple popups noteInput = document.createElement('div'); noteInput.className = 'tanuki-note-input'; noteInput.addEventListener('click', e => e.stopPropagation()); // Prevent closing on inner click 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); // Position relative to video 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%)'; // Center using transform // Focus input after slight delay 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 }; // Check if timestamp already exists (only relevant if creating new) if (!initialNote) { // Only check when adding, not editing via this popup 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); // Add or update marker // If manager is open, add/update the item if (manager) { const list = manager.querySelector('.tanuki-manager-list'); const existingItem = list?.querySelector(`.tanuki-timestamp-item[data-time="${time}"]`); // Add optional chaining for list if (existingItem) { updateTimestampItem(existingItem, ts); } else if (list) { // Ensure list exists before appending const newItem = createTimestampItem(ts); // Insert sorted const timestamps = await getTimestamps(currentVideoId); // Get fresh sorted list 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); // Append if largest time } checkManagerEmpty(false); // Ensure "empty" message is removed } } cleanup(); showNotification(`Saved ${formatTime(time)}${note ? ` - "${note}"` : ''}`); }; const outsideClick = (e) => { // Close only if click is truly outside the input popup 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(); // Prevent form submission if wrapped saveHandler(); } }); // Use timeout to add listeners after current event cycle 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()); // Prevent clicks closing it immediately // --- Create Header Elements --- 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 = '✕'; // Use multiplication sign X closeButton.title = 'Close Manager (Esc)'; closeButton.className = 'close-btn'; closeButton.addEventListener('click', closeManager); // Use named function header.append(title, closeButton); // Add title and button to header manager.appendChild(header); // Add header to manager // --- List --- const list = document.createElement('div'); list.className = 'tanuki-manager-list'; manager.appendChild(list); // Add list after header // --- Footer --- 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); // Add handler footer.appendChild(deleteAllBtn); manager.appendChild(footer); // Add footer after list // --- Populate List --- const timestamps = await getTimestamps(currentVideoId); if (!timestamps.length) { checkManagerEmpty(true, list); // Show empty message deleteAllBtn.disabled = true; // Disable delete all if no timestamps } else { timestamps.forEach(ts => { const item = createTimestampItem(ts); list.appendChild(item); }); deleteAllBtn.disabled = false; } // --- Position and Display --- positionManager(); document.body.appendChild(manager); // --- Global Listeners for Closing --- // Add listeners AFTER manager is in DOM and initial setup is done setTimeout(() => { document.addEventListener('keydown', managerKeydownHandler); document.addEventListener('click', managerOutsideClickHandler, true); // Capture phase }, 0); } // --- Manager Helper Functions --- function closeManager() { if (manager) { manager.remove(); manager = null; // Remove global listeners when manager closes document.removeEventListener('keydown', managerKeydownHandler); document.removeEventListener('click', managerOutsideClickHandler, true); } } // Specific handler for manager keydown events function managerKeydownHandler(e) { if (e.key === 'Escape') { // Check if an input field inside the manager has focus const activeElement = document.activeElement; const isInputFocused = manager && manager.contains(activeElement) && activeElement.tagName === 'INPUT'; if (!isInputFocused) { // Only close manager if not editing text closeManager(); } else { // If input is focused, let Escape blur the input first (handled in item creation) activeElement.blur(); } } } // Specific handler for clicks outside the manager function managerOutsideClickHandler(e) { // Close only if click is outside manager and not on the 'M' button that opened it // AND also check if the click is inside a confirmation dialog const isInsideConfirmation = !!e.target.closest('.tanuki-confirmation'); if (manager && !manager.contains(e.target) && !e.target.closest('.tanuki-button[title="Manage timestamps"]') && !isInsideConfirmation) { closeManager(); } } function positionManager() { if (!manager) return; const video = document.querySelector('video'); if (video) { const videoRect = video.getBoundingClientRect(); const managerWidth = 540; // Match CSS const managerHeight = 380; // Match CSS // Calculate centered position, ensuring it stays within viewport bounds 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 = ''; // Reset transform if previously used } else { // Fallback positioning (centered in viewport) manager.style.position = 'fixed'; manager.style.top = '50%'; manager.style.left = '50%'; manager.style.transform = 'translate(-50%, -50%)'; } } // Helper to check if manager list is empty and show/hide message function checkManagerEmpty(forceShow = null, list = null) { // If manager is gone, don't try to access its children if (!manager && !list) { // console.log("checkManagerEmpty: No manager or list provided."); return; } // Prefer passed list, fallback to querying manager IF it still exists const theList = list || manager?.querySelector('.tanuki-manager-list'); const deleteAllBtn = manager?.querySelector('.delete-all-btn'); // Check if theList itself exists now if (!theList) { // This case can happen if the manager was removed concurrently, e.g., during handleDeleteAll // console.warn("checkManagerEmpty: Target list element not found."); return; } const emptyMsgClass = 'tanuki-empty-msg'; let emptyMsg = theList.querySelector(`.${emptyMsgClass}`); // Check for items *within* theList element 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.'; // Updated message theList.prepend(emptyMsg); // Add message at the top } if (deleteAllBtn) deleteAllBtn.disabled = true; // Disable delete all button } else if (forceShow === false || (forceShow === null && hasItems)) { if (emptyMsg) { emptyMsg.remove(); } if (deleteAllBtn) deleteAllBtn.disabled = false; // Enable delete all button } } // --- Handle Delete All --- async function handleDeleteAll() { if (!currentVideoId || !manager) return; // Get manager elements *before* confirmation/await 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; // Should not happen if manager exists, but safety check } 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.`); // Check if manager still exists after confirmation await if (!manager) { console.log("Tanuki Timestamp: Manager was closed during confirmation."); return; } // Re-verify listElement still belongs to the current manager if (!manager.contains(listElement)) { console.error("Tanuki Timestamp: Stale list element reference in handleDeleteAll after confirmation."); return; } if (confirmed) { console.log("Tanuki Timestamp: Deleting all timestamps for video:", currentVideoId); try { // Create an array of delete promises const deletePromises = timestamps.map(ts => deleteTimestamp(currentVideoId, ts.time)); // Wait for all deletions to complete await Promise.all(deletePromises); console.log("Tanuki Timestamp: Database deletions complete."); // Clear UI *after* DB operations // Replace innerHTML setting with safe node removal while (listElement.firstChild) { listElement.removeChild(listElement.firstChild); } console.log("Tanuki Timestamp: Manager list cleared safely."); removeProgressMarkers(); // <<<< Call marker removal console.log("Tanuki Timestamp: Progress markers removed."); // Update manager state using the list reference checkManagerEmpty(true, listElement); // Show empty message and disable button console.log("Tanuki Timestamp: Manager state updated (empty)."); showNotification("All timestamps deleted successfully."); } catch (error) { console.error("Tanuki Timestamp: Error deleting all timestamps:", error); showNotification("Error occurred while deleting timestamps."); // Attempt to restore sensible state if possible if (deleteAllButton) deleteAllButton.disabled = timestamps.length === 0; } } else { console.log("Tanuki Timestamp: Delete all cancelled."); } } // --- Manager Timestamp Item Creation & Editing --- (Inline Editing Logic) function createTimestampItem(ts) { const item = document.createElement('div'); item.className = 'tanuki-timestamp-item'; item.dataset.time = ts.time; // Store time for easy access 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 = '▶'; // Using a play symbol goBtn.title = 'Go to timestamp'; goBtn.className = 'go-btn'; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '✕'; // Using a cross symbol deleteBtn.title = 'Delete timestamp'; deleteBtn.className = 'delete-btn'; // Create a container for the buttons for better layout control if needed const buttonContainer = document.createElement('div'); buttonContainer.style.display = 'flex'; // Keep buttons inline buttonContainer.style.alignItems = 'center'; buttonContainer.append(goBtn, deleteBtn); item.append(timeEl, noteEl, buttonContainer); // Add button container // --- Event Listeners --- // Go to time goBtn.addEventListener('click', () => { const video = document.querySelector('video'); if (video) { video.currentTime = ts.time; // Optional: Close manager after clicking Go // closeManager(); } }); // Delete Single Item deleteBtn.addEventListener('click', async () => { // Find the item again in case `ts` object is stale (unlikely here, but good practice) const currentItemTime = parseInt(item.dataset.time); const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(currentItemTime)}?`); if (confirmed) { await deleteTimestamp(currentVideoId, currentItemTime); removeMarker(currentItemTime); // Remove from progress bar item.remove(); checkManagerEmpty(); // Check if list is now empty after deletion } }); // --- Inline Editing Functions --- const makeEditable = (element, inputClass, originalValue, saveCallback, validationCallback = null) => { // Check if the *parent* of the element is currently showing an input if (element.parentNode && element.parentNode.querySelector('input')) return; const input = document.createElement('input'); input.type = 'text'; input.className = inputClass; input.value = originalValue; // Store reference to the original element being replaced const originalElement = element; originalElement.replaceWith(input); input.focus(); input.select(); let isSaving = false; // Flag to prevent concurrent saves on blur/enter const saveChanges = async () => { // If input is no longer in the DOM (e.g., parent removed), exit if (!input.parentNode) { // console.log("Tanuki Timestamp: Input parent node missing, aborting save."); return false; } if (isSaving) return false; // Prevent re-entry isSaving = true; const newValue = input.value.trim(); // Validation if (validationCallback && !(await validationCallback(newValue))) { input.replaceWith(originalElement); // Revert on invalid input isSaving = false; return false; // Indicate save failed } // Check if value actually changed const originalTimeSeconds = (inputClass === 'time-input') ? parseTime(originalElement.textContent) : null; const hasChanged = (inputClass === 'time-input') ? parseTime(newValue) !== originalTimeSeconds // Compare parsed seconds : newValue !== (ts.note || ''); // Compare trimmed string note if (hasChanged) { try { // Pass input & original element to callback, await its completion await saveCallback(newValue, input, originalElement); // Callback is now responsible for replacing input with originalElement } catch (error) { console.error("Tanuki Timestamp: Error during save callback:", error); // Ensure replacement happens even on error in callback if (input.parentNode) input.replaceWith(originalElement); } } else { // Only replace if input is still in DOM if (input.parentNode) input.replaceWith(originalElement); } isSaving = false; return true; // Indicate save (or revert due to no change) succeeded }; const handleBlur = async (e) => { // Small delay to allow clicking other buttons within the item if needed // Check if focus moved to another element within the *same item* const relatedTarget = e.relatedTarget; // Only save if focus moves outside the item, or to something non-interactive inside if (!relatedTarget || !item.contains(relatedTarget) || !['BUTTON', 'INPUT', 'A'].includes(relatedTarget.tagName)) { await saveChanges(); } }; input.addEventListener('blur', (e) => setTimeout(() => handleBlur(e), 150)); // Increased delay slightly input.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { e.preventDefault(); await saveChanges(); } else if (e.key === 'Escape') { e.preventDefault(); // Check if input is still in DOM before replacing if (input.parentNode) { input.replaceWith(originalElement); // Cancel edit on Escape } } }); }; // Edit Time (Double Click) timeEl.addEventListener('dblclick', () => { makeEditable(timeEl, 'time-input', timeEl.textContent, async (newTimeString, inputElement, originalDisplayElement) => { // saveCallback const newTime = parseTime(newTimeString); const oldTime = ts.time; // Update DB: Delete old, add new await deleteTimestamp(currentVideoId, oldTime); await saveTimestamp(currentVideoId, newTime, ts.note); // Update internal state and UI element ts.time = newTime; item.dataset.time = newTime; // Update item's data attribute originalDisplayElement.textContent = formatTime(newTime); // Update the original span's text if (inputElement.parentNode) inputElement.replaceWith(originalDisplayElement); // Put the original span back updateMarker(oldTime, newTime, ts.note); // Update progress marker // Re-sort items in the manager list visually 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)); // Re-append in sorted order } showNotification(`Time updated to ${formatTime(newTime)}`); }, async (newTimeString) => { // validationCallback (async) const newTime = parseTime(newTimeString); if (newTime === null || newTime < 0) { showNotification('Invalid time format (HH:MM:SS)'); return false; } // Check if time already exists (and it's not the original time) 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; // Validation passed } ); }); // Edit Note (Double Click) noteEl.addEventListener('dblclick', () => { makeEditable(noteEl, 'note-input', ts.note || '', // Use actual note or empty string if placeholder async (newNote, inputElement, originalDisplayElement) => { // saveCallback await saveTimestamp(currentVideoId, ts.time, newNote); // Update internal state and UI element 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'); } // Replace input with the updated original element if (inputElement.parentNode) inputElement.replaceWith(originalDisplayElement); updateMarker(ts.time, ts.time, newNote); // Update progress marker tooltip info showNotification(`Note updated for ${formatTime(ts.time)}`); } // No specific validation needed for notes other than trimming which happens in saveChanges ); }); return item; } // --- Update existing item in manager (used after adding/saving via Note Input) --- (No changes needed) 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; // Ensure data attribute is updated } // --- Initialization and Video Change Detection --- let initInterval = setInterval(() => { const videoId = getCurrentVideoId(); const videoPlayer = document.querySelector('video'); const controlsExist = !!document.querySelector('.ytp-left-controls'); // Check if controls are loaded // Wait for video metadata (readyState >= 1) and controls if (videoId && videoPlayer && videoPlayer.readyState >= 1 && controlsExist) { if (videoId !== currentVideoId) { // Video changed or first load for this video ID // console.log("Tanuki Timestamp: Video detected/changed - ", videoId); cleanupUI(); // Clean up previous UI if any currentVideoId = videoId; // Use timeout to ensure player is fully ready for UI injection setTimeout(setupUI, 500); } else if (!uiContainer && currentVideoId === videoId) { // If video ID is the same but UI isn't there (e.g., navigating back/forth quickly, or initial load race condition) // console.log("Tanuki Timestamp: Re-initializing UI for ", videoId); cleanupUI(); // Clean up just in case parts exist setTimeout(setupUI, 500); // Attempt to setup UI again } } else if (currentVideoId && (!videoId || !videoPlayer || !controlsExist)) { // More robust check for leaving video // Navigated away from a video page (or video element/controls removed) // console.log("Tanuki Timestamp: Navigated away or video/controls lost, cleaning up."); cleanupUI(); currentVideoId = null; } // Keep checking even if video not found initially, YT navigation might load it later }, 1000); // Check every second })();