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