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