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