YouTube Timestamp Manager

Full-featured timestamp manager with video tracking, inline editing, seeking, and sharp UI.

目前为 2025-04-09 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Timestamp Manager
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.3
  5. // @description Full-featured timestamp manager with video tracking, inline editing, seeking, and sharp UI.
  6. // @author Tanuki
  7. // @match *://www.youtube.com/*
  8. // @icon https://www.youtube.com/s/desktop/8fa11322/img/favicon_144x144.png
  9. // @grant none
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15. const DB_NAME = 'TanStampsDB';
  16. const DB_VERSION = 1;
  17. const STORE_NAME = 'timestamps';
  18. const NOTE_PLACEHOLDER = '[No note]'; // Placeholder text for empty notes
  19.  
  20. let currentVideoId = null;
  21. let manager = null;
  22. let noteInput = null;
  23. let uiContainer = null;
  24. let progressMarkers = [];
  25. let currentTooltip = null;
  26. let updateMarkers = null;
  27.  
  28. // Inject CSS into <head>
  29. const style = document.createElement('style');
  30. style.textContent = `
  31. .tanuki-ui-container {
  32. display: inline-flex;
  33. align-items: center;
  34. margin-left: 8px;
  35. }
  36. .tanuki-timestamp {
  37. cursor: pointer;
  38. color: #fff;
  39. font-family: Arial, sans-serif;
  40. font-size: 14px;
  41. line-height: 24px;
  42. margin: 0 4px;
  43. user-select: none;
  44. }
  45. .tanuki-button {
  46. background: #333;
  47. color: #fff;
  48. border: 1px solid #555;
  49. padding: 4px 8px;
  50. border-radius: 0;
  51. cursor: pointer;
  52. font-size: 12px;
  53. transition: background 0.2s, border-color 0.2s;
  54. margin: 0 2px;
  55. user-select: none;
  56. }
  57. .tanuki-button:hover {
  58. background: #444;
  59. border-color: #777;
  60. }
  61. .tanuki-button:active {
  62. background: #222;
  63. }
  64. .tanuki-progress-marker {
  65. position: absolute;
  66. height: 100%;
  67. width: 3px;
  68. background: #3ea6ff;
  69. box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  70. z-index: 999;
  71. pointer-events: auto;
  72. transform: translateX(-1.5px);
  73. cursor: pointer;
  74. border-radius: 0;
  75. }
  76. .tanuki-tooltip {
  77. position: fixed;
  78. background: rgba(0, 0, 0, 0.9);
  79. color: #fff;
  80. padding: 8px 12px;
  81. border-radius: 0;
  82. font-size: 12px;
  83. white-space: nowrap;
  84. z-index: 10000;
  85. pointer-events: none;
  86. transform: translate(-50%, -100%);
  87. margin-top: -4px;
  88. }
  89. .tanuki-note-input {
  90. position: fixed;
  91. background: rgba(30, 30, 30, 0.95);
  92. color: #fff;
  93. padding: 20px;
  94. border-radius: 0;
  95. z-index: 10000;
  96. display: flex;
  97. flex-direction: column;
  98. align-items: center;
  99. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
  100. border: 1px solid #555;
  101. }
  102. .tanuki-note-input input {
  103. padding: 8px 10px;
  104. margin-bottom: 12px;
  105. border: 1px solid #666;
  106. border-radius: 0;
  107. width: 220px;
  108. background: #222;
  109. color: #fff;
  110. font-size: 14px;
  111. }
  112. .tanuki-note-input input:focus {
  113. outline: none;
  114. border-color: #3ea6ff;
  115. }
  116. .tanuki-note-input button {
  117. background: #007bff;
  118. border: none;
  119. border-radius: 0;
  120. padding: 8px 16px;
  121. cursor: pointer;
  122. color: #fff;
  123. font-weight: bold;
  124. transition: background 0.2s;
  125. }
  126. .tanuki-note-input button:hover {
  127. background: #0056b3;
  128. }
  129. .tanuki-manager {
  130. position: fixed;
  131. background: rgba(25, 25, 25, 0.97);
  132. color: #eee;
  133. padding: 15px 20px 20px 20px;
  134. border-radius: 0;
  135. z-index: 99999;
  136. width: 540px;
  137. height: 380px;
  138. overflow: hidden;
  139. box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
  140. border: 1px solid #444;
  141. display: flex;
  142. flex-direction: column;
  143. }
  144. .tanuki-manager-header {
  145. display: flex;
  146. justify-content: space-between;
  147. align-items: center;
  148. margin-bottom: 15px;
  149. padding-bottom: 10px;
  150. border-bottom: 1px solid #555;
  151. flex-shrink: 0;
  152. }
  153. .tanuki-manager h3 {
  154. margin: 0;
  155. padding: 0;
  156. border-bottom: none;
  157. color: #fff;
  158. font-size: 18px;
  159. text-align: left;
  160. flex-grow: 1;
  161. line-height: 1.2;
  162. }
  163. .tanuki-manager button.close-btn {
  164. background: #666;
  165. border: 1px solid #888;
  166. color: #fff;
  167. font-size: 18px;
  168. font-weight: bold;
  169. line-height: 1;
  170. padding: 3px 7px;
  171. border-radius: 0;
  172. cursor: pointer;
  173. transition: background 0.2s, transform 0.2s;
  174. position: static;
  175. margin-left: 10px;
  176. flex-shrink: 0;
  177. }
  178. .tanuki-manager button.close-btn:hover {
  179. background: #777;
  180. transform: scale(1.1);
  181. }
  182. .tanuki-manager-list {
  183. display: flex;
  184. flex-direction: column;
  185. gap: 8px;
  186. flex-grow: 1;
  187. overflow-y: auto;
  188. margin-bottom: 15px;
  189. }
  190. .tanuki-timestamp-item {
  191. display: flex;
  192. justify-content: space-between;
  193. align-items: center;
  194. background: #3a3a3a;
  195. padding: 10px 12px;
  196. border-radius: 0;
  197. transition: background 0.15s;
  198. font-size: 15px;
  199. }
  200. .tanuki-timestamp-item:hover {
  201. background: #4a4a4a;
  202. }
  203. .tanuki-timestamp-item span:first-child {
  204. margin-right: 12px;
  205. cursor: pointer;
  206. min-width: 70px;
  207. text-align: right;
  208. font-weight: bold;
  209. color: #3ea6ff;
  210. user-select: none;
  211. }
  212. .tanuki-timestamp-item span:nth-child(2) {
  213. flex: 1;
  214. margin-right: 12px;
  215. cursor: pointer;
  216. overflow: hidden;
  217. text-overflow: ellipsis;
  218. white-space: nowrap;
  219. color: #ddd;
  220. user-select: none;
  221. }
  222. .tanuki-timestamp-item .tanuki-note-placeholder {
  223. color: #999;
  224. font-style: italic;
  225. }
  226. .tanuki-timestamp-item input {
  227. padding: 6px 8px;
  228. border: 1px solid #666;
  229. border-radius: 0;
  230. background: #222;
  231. color: #fff;
  232. font-size: 15px;
  233. font-family: inherit;
  234. box-sizing: border-box;
  235. }
  236. .tanuki-timestamp-item input:focus {
  237. outline: none;
  238. border-color: #3ea6ff;
  239. }
  240. .tanuki-timestamp-item input.time-input {
  241. width: 80px;
  242. text-align: right;
  243. font-weight: bold;
  244. color: #3ea6ff;
  245. }
  246. .tanuki-timestamp-item input.note-input {
  247. flex: 1;
  248. margin-right: 12px;
  249. }
  250. .tanuki-timestamp-item button {
  251. background: #555;
  252. border: 1px solid #777;
  253. padding: 4px 8px;
  254. border-radius: 0;
  255. cursor: pointer;
  256. color: #fff;
  257. margin-left: 6px;
  258. font-size: 16px;
  259. line-height: 1;
  260. transition: background 0.2s, border-color 0.2s;
  261. }
  262. .tanuki-timestamp-item button:hover {
  263. background: #666;
  264. border-color: #888;
  265. }
  266. .tanuki-timestamp-item button.delete-btn {
  267. background: #d9534f;
  268. border-color: #d43f3a;
  269. }
  270. .tanuki-timestamp-item button.delete-btn:hover {
  271. background: #c9302c;
  272. border-color: #ac2925;
  273. }
  274. .tanuki-timestamp-item button.go-btn {
  275. background: #5cb85c;
  276. border-color: #4cae4c;
  277. }
  278. .tanuki-timestamp-item button.go-btn:hover {
  279. background: #449d44;
  280. border-color: #398439;
  281. }
  282. .tanuki-manager-footer {
  283. display: flex;
  284. justify-content: flex-end;
  285. flex-shrink: 0;
  286. padding-top: 10px;
  287. border-top: 1px solid #555;
  288. }
  289. .tanuki-manager button.delete-all-btn {
  290. background: #c9302c;
  291. border: 1px solid #ac2925;
  292. color: #fff;
  293. padding: 6px 12px;
  294. border-radius: 0;
  295. cursor: pointer;
  296. font-size: 13px;
  297. font-weight: bold;
  298. transition: background 0.2s, border-color 0.2s;
  299. }
  300. .tanuki-manager button.delete-all-btn:hover {
  301. background: #ac2925;
  302. border-color: #761c19;
  303. }
  304. .tanuki-manager button.delete-all-btn:disabled {
  305. background: #777;
  306. border-color: #999;
  307. color: #ccc;
  308. cursor: not-allowed;
  309. }
  310. .tanuki-empty-msg {
  311. color: #999;
  312. text-align: center;
  313. padding: 20px;
  314. font-style: italic;
  315. font-size: 14px;
  316. }
  317. .tanuki-notification {
  318. position: fixed;
  319. background: rgba(20, 20, 20, 0.9);
  320. color: #fff;
  321. padding: 12px 20px;
  322. border-radius: 0;
  323. font-size: 14px;
  324. transition: opacity 0.4s ease-out, transform 0.4s ease-out;
  325. z-index: 100001;
  326. pointer-events: none;
  327. box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
  328. border: 1px solid #444;
  329. opacity: 0;
  330. transform: translate(-50%, -50%) scale(0.9);
  331. }
  332. .tanuki-notification.show {
  333. opacity: 1;
  334. transform: translate(-50%, -50%) scale(1);
  335. }
  336. .tanuki-confirmation {
  337. position: fixed;
  338. background: rgba(30, 30, 30, 0.95);
  339. color: #eee;
  340. padding: 25px;
  341. border-radius: 0;
  342. z-index: 100000;
  343. min-width: 320px;
  344. text-align: center;
  345. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
  346. border: 1px solid #555;
  347. }
  348. .tanuki-confirmation div.tanuki-confirmation-message {
  349. margin-bottom: 18px;
  350. font-size: 15px;
  351. line-height: 1.4;
  352. }
  353. .tanuki-confirmation button {
  354. border: none;
  355. padding: 10px 20px;
  356. border-radius: 0;
  357. cursor: pointer;
  358. color: #fff;
  359. font-weight: bold;
  360. font-size: 14px;
  361. transition: background 0.2s, transform 0.1s;
  362. margin: 0 5px;
  363. }
  364. .tanuki-confirmation button:hover {
  365. transform: translateY(-1px);
  366. }
  367. .tanuki-confirmation button:active {
  368. transform: translateY(0px);
  369. }
  370. .tanuki-confirmation button.confirm-btn {
  371. background: #d9534f;
  372. }
  373. .tanuki-confirmation button.confirm-btn:hover {
  374. background: #c9302c;
  375. }
  376. .tanuki-confirmation button.cancel-btn {
  377. background: #555;
  378. }
  379. .tanuki-confirmation button.cancel-btn:hover {
  380. background: #666;
  381. }
  382. `;
  383. document.head.appendChild(style);
  384.  
  385. // --- Database Functions ---
  386. async function openDatabase() {
  387. return new Promise((resolve, reject) => {
  388. const request = indexedDB.open(DB_NAME, DB_VERSION);
  389. request.onupgradeneeded = (event) => {
  390. const db = event.target.result;
  391. if (!db.objectStoreNames.contains(STORE_NAME)) {
  392. db.createObjectStore(STORE_NAME, { keyPath: ['videoId', 'time'] });
  393. }
  394. };
  395. request.onsuccess = (event) => resolve(event.target.result);
  396. request.onerror = (event) => reject(`Database error: ${event.target.error}`);
  397. });
  398. }
  399.  
  400. async function getTimestamps(videoId) {
  401. try {
  402. const db = await openDatabase();
  403. return new Promise((resolve, reject) => {
  404. const transaction = db.transaction(STORE_NAME, 'readonly');
  405. const store = transaction.objectStore(STORE_NAME);
  406. const request = store.getAll();
  407. request.onsuccess = (event) => {
  408. resolve(event.target.result
  409. .filter(t => t.videoId === videoId)
  410. .sort((a, b) => a.time - b.time));
  411. };
  412. request.onerror = (event) => reject(event.target.error);
  413. });
  414. } catch (error) {
  415. console.error('Error loading timestamps:', error);
  416. return [];
  417. }
  418. }
  419.  
  420. async function saveTimestamp(videoId, time, note) {
  421. try {
  422. const db = await openDatabase();
  423. return new Promise((resolve, reject) => {
  424. const transaction = db.transaction(STORE_NAME, 'readwrite');
  425. const store = transaction.objectStore(STORE_NAME);
  426. const request = store.put({ videoId, time, note });
  427. request.onsuccess = () => resolve();
  428. request.onerror = (event) => reject(event.target.error);
  429. });
  430. } catch (error) {
  431. console.error('Error saving timestamp:', error);
  432. }
  433. }
  434.  
  435. async function deleteTimestamp(videoId, time) {
  436. try {
  437. const db = await openDatabase();
  438. return new Promise((resolve, reject) => {
  439. const transaction = db.transaction(STORE_NAME, 'readwrite');
  440. const store = transaction.objectStore(STORE_NAME);
  441. const request = store.delete([videoId, time]);
  442. request.onsuccess = () => resolve();
  443. request.onerror = (event) => reject(event.target.error);
  444. });
  445. } catch (error) {
  446. console.error('Error deleting timestamp:', error);
  447. }
  448. }
  449.  
  450. // --- Utility Functions ---
  451. function getCurrentVideoId() {
  452. const urlParams = new URLSearchParams(window.location.search);
  453. return urlParams.get('v');
  454. }
  455.  
  456. function formatTime(seconds) {
  457. const h = Math.floor(seconds / 3600);
  458. const m = Math.floor((seconds % 3600) / 60);
  459. const s = Math.floor(seconds % 60);
  460. return [h, m, s].map(n => n.toString().padStart(2, '0')).join(':');
  461. }
  462.  
  463. function parseTime(timeString) {
  464. const parts = timeString.split(':').map(Number);
  465. if (parts.some(isNaN) || parts.length < 2 || parts.length > 3) {
  466. return null; // Invalid format
  467. }
  468. while (parts.length < 3) {
  469. parts.unshift(0); // Pad with hours/minutes if missing
  470. }
  471. const [h, m, s] = parts;
  472. if (h < 0 || m < 0 || m > 59 || s < 0 || s > 59) {
  473. return null; // Invalid values
  474. }
  475. return h * 3600 + m * 60 + s;
  476. }
  477.  
  478. function isLiveStream() {
  479. const timeDisplay = document.querySelector('.ytp-time-display');
  480. return timeDisplay && timeDisplay.classList.contains('ytp-live');
  481. }
  482.  
  483. // --- UI Notification & Confirmation ---
  484. function showNotification(message) {
  485. // Remove existing toast if any
  486. const existingToast = document.querySelector('.tanuki-notification');
  487. if (existingToast) existingToast.remove();
  488.  
  489. const toast = document.createElement('div');
  490. toast.className = 'tanuki-notification';
  491. toast.textContent = message;
  492.  
  493. document.body.appendChild(toast);
  494. const video = document.querySelector('video');
  495.  
  496. // Center positioning (relative to viewport or video)
  497. if (video) {
  498. const videoRect = video.getBoundingClientRect();
  499. // Position near top-center of video player
  500. toast.style.left = `${videoRect.left + videoRect.width / 2}px`;
  501. toast.style.top = `${videoRect.top + 50}px`; // Offset from top
  502. // Ensure transform origin is correct for centering
  503. toast.style.transform = 'translateX(-50%) scale(0.9)'; // Initial state for animation
  504. } else { // Fallback if video isn't found
  505. toast.style.left = '50%';
  506. toast.style.top = '10%'; // Near top of viewport
  507. // Ensure transform origin is correct for centering
  508. toast.style.transform = 'translateX(-50%) scale(0.9)'; // Initial state for animation
  509. }
  510.  
  511.  
  512. // Trigger the animation
  513. requestAnimationFrame(() => {
  514. toast.classList.add('show'); // Add class to animate in
  515. });
  516.  
  517. // Auto-remove after delay
  518. setTimeout(() => {
  519. toast.style.opacity = '0';
  520. toast.style.transform = toast.style.transform.replace('scale(1)', 'scale(0.9)'); // Animate out
  521. setTimeout(() => toast.remove(), 400); // Remove after fade out animation
  522. }, 2500); // Increased display time slightly
  523. }
  524.  
  525.  
  526. function showConfirmation(message) {
  527. return new Promise(resolve => {
  528. // Remove existing confirmation if any
  529. const existingModal = document.querySelector('.tanuki-confirmation');
  530. if (existingModal) existingModal.remove();
  531.  
  532. const modal = document.createElement('div');
  533. modal.className = 'tanuki-confirmation';
  534. // Stop clicks inside modal from propagating to manager's outside click listener
  535. modal.addEventListener('click', e => e.stopPropagation());
  536.  
  537. const video = document.querySelector('video');
  538. // Center positioning (relative to viewport or video)
  539. if (video) {
  540. const videoRect = video.getBoundingClientRect();
  541. modal.style.left = `${videoRect.left + videoRect.width / 2}px`;
  542. modal.style.top = `${videoRect.top + videoRect.height / 2}px`;
  543. modal.style.transform = 'translate(-50%, -50%)'; // Center using transform
  544. } else { // Fallback positioning
  545. modal.style.position = 'fixed';
  546. modal.style.top = '50%';
  547. modal.style.left = '50%';
  548. modal.style.transform = 'translate(-50%, -50%)';
  549. }
  550.  
  551. const messageEl = document.createElement('div');
  552. messageEl.textContent = message;
  553. messageEl.className = 'tanuki-confirmation-message'; // Add class for styling
  554.  
  555. const buttonContainer = document.createElement('div'); // Container for buttons
  556.  
  557. const confirmBtn = document.createElement('button');
  558. confirmBtn.textContent = 'Confirm';
  559. confirmBtn.className = 'confirm-btn'; // Add class for styling
  560. confirmBtn.addEventListener('click', (e) => {
  561. // e.stopPropagation(); // Already stopped by modal listener
  562. resolve(true);
  563. cleanup();
  564. });
  565.  
  566. const cancelBtn = document.createElement('button');
  567. cancelBtn.textContent = 'Cancel';
  568. cancelBtn.className = 'cancel-btn'; // Add class for styling
  569. cancelBtn.addEventListener('click', (e) => {
  570. // e.stopPropagation(); // Already stopped by modal listener
  571. resolve(false);
  572. cleanup();
  573. });
  574.  
  575. buttonContainer.append(confirmBtn, cancelBtn); // Add buttons to container
  576. modal.append(messageEl, buttonContainer); // Add message and button container
  577. document.body.appendChild(modal);
  578.  
  579. let timeoutId = null;
  580.  
  581. const cleanup = () => {
  582. if (modal.parentNode) {
  583. document.body.removeChild(modal);
  584. }
  585. // Remove the document-level listeners specific to this confirmation
  586. document.removeEventListener('click', outsideClickForConfirm, true);
  587. document.removeEventListener('keydown', keyHandlerForConfirm);
  588. clearTimeout(timeoutId);
  589. };
  590.  
  591. // Listener specifically for clicks outside *this confirmation modal*
  592. const outsideClickForConfirm = (e) => {
  593. // If the click is outside the modal, resolve false and cleanup
  594. if (!modal.contains(e.target)) {
  595. resolve(false);
  596. cleanup();
  597. }
  598. };
  599.  
  600. // Listener specifically for keydowns while *this confirmation modal* is open
  601. const keyHandlerForConfirm = (e) => {
  602. if (e.key === 'Escape') {
  603. resolve(false);
  604. cleanup();
  605. } else if (e.key === 'Enter') {
  606. // Optional: Confirm on Enter
  607. // resolve(true);
  608. // cleanup();
  609. }
  610. };
  611.  
  612. // Use timeout to add listeners after current event cycle finishes
  613. // Add the specific listeners for this modal instance
  614. timeoutId = setTimeout(() => {
  615. document.addEventListener('click', outsideClickForConfirm, true); // Capture phase
  616. document.addEventListener('keydown', keyHandlerForConfirm);
  617. confirmBtn.focus(); // Focus the confirm button by default
  618. }, 0);
  619. });
  620. }
  621.  
  622. // --- UI Cleanup ---
  623. function cleanupUI() {
  624. if (manager) {
  625. closeManager(); // Use the dedicated close function which handles listeners
  626. }
  627. if (noteInput) {
  628. noteInput.remove();
  629. noteInput = null;
  630. // Potentially remove noteInput specific listeners if added globally
  631. }
  632. if (uiContainer) {
  633. uiContainer.remove();
  634. uiContainer = null;
  635. }
  636. removeProgressMarkers();
  637. if (currentTooltip) {
  638. currentTooltip.remove();
  639. currentTooltip = null;
  640. }
  641. const video = document.querySelector('video');
  642. if (updateMarkers && video) {
  643. video.removeEventListener('timeupdate', updateMarkers);
  644. updateMarkers = null;
  645. }
  646. }
  647.  
  648. // --- Progress Bar Markers ---
  649. function removeProgressMarkers() {
  650. progressMarkers.forEach((marker, index) => {
  651. try {
  652. if (marker && marker.parentNode) { // Check if marker exists and is in DOM
  653. marker.remove();
  654. }
  655. } catch (e) {
  656. console.error(`Tanuki Timestamp: Error removing marker at index ${index}:`, e);
  657. }
  658. });
  659. progressMarkers = []; // Clears the array reference
  660. }
  661.  
  662.  
  663. function updateMarker(oldTime, newTime, newNote) {
  664. const marker = progressMarkers.find(m => parseInt(m.dataset.time) === oldTime);
  665. if (!marker) return;
  666.  
  667. marker.dataset.time = newTime;
  668. marker.dataset.note = newNote || '';
  669. marker.title = formatTime(newTime) + (newNote ? ` - ${newNote}` : ''); // Update title
  670.  
  671. // Recalculate position
  672. const video = document.querySelector('video');
  673. const progressBar = document.querySelector('.ytp-progress-bar');
  674. if (!video || !progressBar) return;
  675.  
  676. const isLive = isLiveStream();
  677. const duration = isLive ? video.currentTime : video.duration;
  678. if (!duration || isNaN(duration) || duration <= 0) return; // Added duration > 0 check
  679.  
  680. const position = Math.min(100, Math.max(0, (newTime / duration) * 100)); // Clamp between 0 and 100
  681. marker.style.left = `${position}%`;
  682. }
  683.  
  684. function removeMarker(time) {
  685. const index = progressMarkers.findIndex(m => parseInt(m.dataset.time) === time);
  686. if (index !== -1) {
  687. const markerToRemove = progressMarkers[index];
  688. if (markerToRemove && markerToRemove.parentNode) {
  689. markerToRemove.remove();
  690. }
  691. progressMarkers.splice(index, 1); // Remove from array regardless of DOM state
  692. }
  693. }
  694.  
  695. async function createProgressMarkers() {
  696. removeProgressMarkers(); // Clear existing before adding new ones
  697. const video = document.querySelector('video');
  698. const progressBar = document.querySelector('.ytp-progress-bar');
  699. if (!video || !progressBar || !currentVideoId) return;
  700.  
  701. const timestamps = await getTimestamps(currentVideoId);
  702. const isLive = isLiveStream();
  703. const duration = isLive ? video.currentTime : video.duration;
  704. if (!duration || isNaN(duration) || duration <= 0) return; // Added duration > 0 check
  705.  
  706. timestamps.forEach(ts => {
  707. addProgressMarker(ts, duration); // Pass duration to avoid recalculating
  708. });
  709. }
  710.  
  711. function addProgressMarker(ts, videoDuration = null) {
  712. const progressBar = document.querySelector('.ytp-progress-bar');
  713. if (!progressBar) return;
  714.  
  715. let duration = videoDuration;
  716. if (duration === null) {
  717. const video = document.querySelector('video');
  718. if (!video) return;
  719. const isLive = isLiveStream();
  720. duration = isLive ? video.currentTime : video.duration;
  721. }
  722.  
  723. if (!duration || isNaN(duration) || duration <= 0) return; // Check duration validity
  724.  
  725. // Check if marker already exists for this time *in the array*
  726. const existingMarkerIndex = progressMarkers.findIndex(m => parseInt(m.dataset.time) === ts.time);
  727. if (existingMarkerIndex !== -1) {
  728. // Update existing marker's note and ensure position is correct
  729. const existingMarker = progressMarkers[existingMarkerIndex];
  730. existingMarker.dataset.note = ts.note || '';
  731. existingMarker.title = formatTime(ts.time) + (ts.note ? ` - ${ts.note}` : '');
  732. const position = Math.min(100, Math.max(0, (ts.time / duration) * 100));
  733. existingMarker.style.left = `${position}%`;
  734. return;
  735. }
  736.  
  737. // Create and add new marker
  738. const marker = document.createElement('div');
  739. marker.className = 'tanuki-progress-marker';
  740. const position = Math.min(100, Math.max(0, (ts.time / duration) * 100)); // Clamp position
  741. marker.style.left = `${position}%`;
  742. marker.dataset.time = ts.time;
  743. marker.dataset.note = ts.note || '';
  744. marker.title = formatTime(ts.time) + (ts.note ? ` - ${ts.note}` : ''); // Add title for hover info
  745. marker.addEventListener('mouseenter', showMarkerTooltip);
  746. marker.addEventListener('mouseleave', hideMarkerTooltip);
  747. marker.addEventListener('click', (e) => { // Seek on marker click
  748. e.stopPropagation(); // Prevent progress bar seek if user clicks marker directly
  749. const video = document.querySelector('video');
  750. if (video) video.currentTime = ts.time;
  751. });
  752. progressBar.appendChild(marker);
  753. progressMarkers.push(marker); // Add to array *after* adding to DOM
  754. }
  755.  
  756.  
  757. function showMarkerTooltip(e) {
  758. if (currentTooltip) currentTooltip.remove(); // Remove previous instantly
  759.  
  760. const marker = e.target;
  761. const note = marker.dataset.note;
  762. const time = parseInt(marker.dataset.time);
  763. const formattedTime = formatTime(time);
  764.  
  765. const tooltipText = note ? `${formattedTime} - ${note}` : formattedTime;
  766.  
  767. currentTooltip = document.createElement('div');
  768. currentTooltip.className = 'tanuki-tooltip';
  769. currentTooltip.textContent = tooltipText;
  770.  
  771. const rect = marker.getBoundingClientRect();
  772. // Position tooltip centered above the marker
  773. currentTooltip.style.left = `${rect.left + rect.width / 2}px`;
  774. currentTooltip.style.top = `${rect.top}px`; // Align top with marker top initially
  775. // transform will move it up
  776.  
  777. document.body.appendChild(currentTooltip);
  778. }
  779.  
  780. function hideMarkerTooltip() {
  781. if (currentTooltip) {
  782. currentTooltip.remove();
  783. currentTooltip = null;
  784. }
  785. }
  786.  
  787.  
  788. // --- Main UI Setup ---
  789. function setupUI() {
  790. if (uiContainer) return;
  791.  
  792. const controls = document.querySelector('.ytp-left-controls');
  793. const video = document.querySelector('video');
  794. // Ensure video has duration and controls exist
  795. if (!controls || !video || !video.duration || video.duration <= 0) return;
  796.  
  797. uiContainer = document.createElement('span');
  798. uiContainer.className = 'tanuki-ui-container';
  799.  
  800. const timestampEl = document.createElement('span');
  801. timestampEl.className = 'tanuki-timestamp';
  802. timestampEl.textContent = '00:00:00';
  803. timestampEl.title = 'Click to copy current time';
  804. timestampEl.addEventListener('click', async () => {
  805. const video = document.querySelector('video');
  806. if (video) {
  807. const time = Math.floor(video.currentTime);
  808. try {
  809. await navigator.clipboard.writeText(formatTime(time));
  810. showNotification('Current timestamp copied!');
  811. } catch (error) {
  812. showNotification('Copy failed');
  813. }
  814. }
  815. });
  816.  
  817. const createButton = (label, title, handler) => {
  818. const btn = document.createElement('button');
  819. btn.className = 'tanuki-button';
  820. btn.textContent = label;
  821. btn.title = title;
  822. btn.addEventListener('click', (e) => {
  823. e.stopPropagation(); // Prevent video pause/play
  824. handler();
  825. });
  826. return btn;
  827. };
  828.  
  829. const addButton = createButton('+', 'Add timestamp at current time', async () => {
  830. const video = document.querySelector('video');
  831. if (video && currentVideoId) {
  832. const time = Math.floor(video.currentTime);
  833. showNoteInput(video, time);
  834. }
  835. });
  836.  
  837. const removeButton = createButton('-', 'Remove nearest timestamp', async () => {
  838. const video = document.querySelector('video');
  839. if (video && currentVideoId) {
  840. const currentTime = Math.floor(video.currentTime);
  841. const timestamps = await getTimestamps(currentVideoId);
  842. if (!timestamps.length) {
  843. showNotification('No timestamps to remove');
  844. return;
  845. }
  846. // Find the timestamp closest to the current time
  847. const closest = timestamps.reduce((prev, curr) =>
  848. Math.abs(curr.time - currentTime) < Math.abs(prev.time - currentTime) ? curr : prev
  849. );
  850.  
  851. // Show confirmation dialog
  852. const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(closest.time)}?`);
  853. if (confirmed) {
  854. await deleteTimestamp(currentVideoId, closest.time);
  855. removeMarker(closest.time); // Remove from progress bar
  856. // If manager is open, remove from list
  857. if (manager) {
  858. const itemToRemove = manager.querySelector(`.tanuki-timestamp-item[data-time="${closest.time}"]`);
  859. if (itemToRemove) itemToRemove.remove();
  860. checkManagerEmpty(); // Check if list is now empty
  861. }
  862. showNotification(`Removed ${formatTime(closest.time)}`);
  863. }
  864. }
  865. });
  866.  
  867. const copyButton = createButton('C', 'Copy all timestamps', async () => {
  868. if (!currentVideoId) return;
  869. const timestamps = await getTimestamps(currentVideoId);
  870. if (!timestamps.length) {
  871. showNotification('No timestamps to copy');
  872. return;
  873. }
  874. const formatted = timestamps
  875. .map(t => `${formatTime(t.time)}${t.note ? ` ${t.note}` : ''}`)
  876. .join('\n');
  877. navigator.clipboard.writeText(formatted)
  878. .then(() => showNotification('Copied all timestamps!'));
  879. });
  880.  
  881. const manageButton = createButton('M', 'Manage timestamps', () => showManager());
  882.  
  883. uiContainer.appendChild(timestampEl);
  884. uiContainer.appendChild(addButton);
  885. uiContainer.appendChild(removeButton);
  886. uiContainer.appendChild(copyButton);
  887. uiContainer.appendChild(manageButton);
  888.  
  889. // Insert into controls, trying to place it after volume but before other buttons
  890. const volumePanel = controls.querySelector('.ytp-volume-panel');
  891. if (volumePanel && volumePanel.nextSibling) {
  892. controls.insertBefore(uiContainer, volumePanel.nextSibling);
  893. } else {
  894. controls.appendChild(uiContainer); // Fallback: append at the end
  895. }
  896.  
  897.  
  898. // Update timestamp display
  899. const timeUpdateInterval = setInterval(() => {
  900. const video = document.querySelector('video');
  901. const currentTsEl = uiContainer?.querySelector('.tanuki-timestamp'); // Check if still exists
  902. if (video && currentTsEl) {
  903. currentTsEl.textContent = formatTime(video.currentTime);
  904. } else if (!currentTsEl && timeUpdateInterval) { // Ensure interval exists before clearing
  905. clearInterval(timeUpdateInterval); // Stop interval if element is gone
  906. }
  907. }, 1000);
  908.  
  909. createProgressMarkers();
  910.  
  911. // Handle live stream marker updates
  912. if (isLiveStream() && video) {
  913. updateMarkers = () => {
  914. const currentTime = video.currentTime;
  915. if (!currentTime || currentTime <= 0) return; // Ignore if time is invalid
  916. progressMarkers.forEach(marker => {
  917. const time = parseInt(marker.dataset.time);
  918. if (time <= currentTime) {
  919. const position = Math.min(100, Math.max(0, (time / currentTime) * 100)); // Clamp
  920. marker.style.left = `${position}%`;
  921. } else {
  922. // For live streams, future markers might not be relevant or position is uncertain
  923. marker.style.left = '100%'; // Or hide them: marker.style.display = 'none';
  924. }
  925. });
  926. };
  927. video.addEventListener('timeupdate', updateMarkers);
  928. }
  929. }
  930.  
  931. // --- Note Input Popup ---
  932. function showNoteInput(video, time, initialNote = '') {
  933. if (noteInput) return; // Prevent multiple popups
  934.  
  935. noteInput = document.createElement('div');
  936. noteInput.className = 'tanuki-note-input';
  937. noteInput.addEventListener('click', e => e.stopPropagation()); // Prevent closing on inner click
  938.  
  939. const input = document.createElement('input');
  940. input.type = 'text';
  941. input.placeholder = 'Enter note (optional)';
  942. input.value = initialNote;
  943.  
  944. const saveBtn = document.createElement('button');
  945. saveBtn.textContent = 'Save';
  946.  
  947. noteInput.append(input, saveBtn);
  948. document.body.appendChild(noteInput);
  949.  
  950. // Position relative to video
  951. const videoRect = video.getBoundingClientRect();
  952. noteInput.style.left = `${videoRect.left + videoRect.width / 2}px`;
  953. noteInput.style.top = `${videoRect.top + videoRect.height / 2}px`;
  954. noteInput.style.transform = 'translate(-50%, -50%)'; // Center using transform
  955.  
  956.  
  957. // Focus input after slight delay
  958. setTimeout(() => {
  959. input.focus();
  960. input.setSelectionRange(input.value.length, input.value.length);
  961. }, 50);
  962.  
  963. let timeoutId = null;
  964.  
  965. const cleanup = () => {
  966. if (noteInput && noteInput.parentNode) {
  967. noteInput.remove();
  968. }
  969. noteInput = null;
  970. document.removeEventListener('click', outsideClick, true);
  971. document.removeEventListener('keydown', handleEscape);
  972. clearTimeout(timeoutId);
  973. };
  974.  
  975. const saveHandler = async () => {
  976. const note = input.value.trim();
  977. const ts = { videoId: currentVideoId, time, note };
  978.  
  979. // Check if timestamp already exists (only relevant if creating new)
  980. if (!initialNote) { // Only check when adding, not editing via this popup
  981. const existingTimestamps = await getTimestamps(currentVideoId);
  982. if (existingTimestamps.some(t => t.time === time)) {
  983. const confirmed = await showConfirmation(`Timestamp at ${formatTime(time)} already exists. Overwrite note?`);
  984. if (!confirmed) {
  985. cleanup();
  986. return;
  987. }
  988. }
  989. }
  990.  
  991.  
  992. await saveTimestamp(currentVideoId, time, note);
  993. addProgressMarker(ts); // Add or update marker
  994.  
  995. // If manager is open, add/update the item
  996. if (manager) {
  997. const list = manager.querySelector('.tanuki-manager-list');
  998. const existingItem = list?.querySelector(`.tanuki-timestamp-item[data-time="${time}"]`); // Add optional chaining for list
  999. if (existingItem) {
  1000. updateTimestampItem(existingItem, ts);
  1001. } else if (list) { // Ensure list exists before appending
  1002. const newItem = createTimestampItem(ts);
  1003. // Insert sorted
  1004. const timestamps = await getTimestamps(currentVideoId); // Get fresh sorted list
  1005. let inserted = false;
  1006. const items = list.querySelectorAll('.tanuki-timestamp-item');
  1007. for (let i = 0; i < items.length; i++) {
  1008. const itemTime = parseInt(items[i].dataset.time);
  1009. if (time < itemTime) {
  1010. list.insertBefore(newItem, items[i]);
  1011. inserted = true;
  1012. break;
  1013. }
  1014. }
  1015. if (!inserted) {
  1016. list.appendChild(newItem); // Append if largest time
  1017. }
  1018. checkManagerEmpty(false); // Ensure "empty" message is removed
  1019. }
  1020. }
  1021. cleanup();
  1022. showNotification(`Saved ${formatTime(time)}${note ? ` - "${note}"` : ''}`);
  1023. };
  1024.  
  1025. const outsideClick = (e) => {
  1026. // Close only if click is truly outside the input popup
  1027. if (noteInput && !noteInput.contains(e.target)) {
  1028. cleanup();
  1029. }
  1030. };
  1031.  
  1032. const handleEscape = (e) => {
  1033. if (e.key === 'Escape') {
  1034. cleanup();
  1035. }
  1036. };
  1037.  
  1038. saveBtn.addEventListener('click', (e) => {
  1039. e.stopPropagation();
  1040. saveHandler();
  1041. });
  1042. input.addEventListener('keypress', (e) => {
  1043. if (e.key === 'Enter') {
  1044. e.preventDefault(); // Prevent form submission if wrapped
  1045. saveHandler();
  1046. }
  1047. });
  1048.  
  1049. // Use timeout to add listeners after current event cycle
  1050. timeoutId = setTimeout(() => {
  1051. document.addEventListener('click', outsideClick, true);
  1052. document.addEventListener('keydown', handleEscape);
  1053. }, 0);
  1054. }
  1055.  
  1056. // --- Timestamp Manager Popup ---
  1057. async function showManager() {
  1058. if (!currentVideoId || manager) return;
  1059.  
  1060. manager = document.createElement('div');
  1061. manager.className = 'tanuki-manager';
  1062. manager.addEventListener('click', e => e.stopPropagation()); // Prevent clicks closing it immediately
  1063.  
  1064. // --- Create Header Elements ---
  1065. const header = document.createElement('div');
  1066. header.className = 'tanuki-manager-header';
  1067.  
  1068. const title = document.createElement('h3');
  1069. title.textContent = 'Timestamp Manager';
  1070.  
  1071. const closeButton = document.createElement('button');
  1072. closeButton.textContent = '✕'; // Use multiplication sign X
  1073. closeButton.title = 'Close Manager (Esc)';
  1074. closeButton.className = 'close-btn';
  1075. closeButton.addEventListener('click', closeManager); // Use named function
  1076.  
  1077. header.append(title, closeButton); // Add title and button to header
  1078. manager.appendChild(header); // Add header to manager
  1079.  
  1080. // --- List ---
  1081. const list = document.createElement('div');
  1082. list.className = 'tanuki-manager-list';
  1083. manager.appendChild(list); // Add list after header
  1084.  
  1085. // --- Footer ---
  1086. const footer = document.createElement('div');
  1087. footer.className = 'tanuki-manager-footer';
  1088.  
  1089. const deleteAllBtn = document.createElement('button');
  1090. deleteAllBtn.textContent = 'Delete All Timestamps';
  1091. deleteAllBtn.title = 'Delete all timestamps for this video';
  1092. deleteAllBtn.className = 'delete-all-btn';
  1093. deleteAllBtn.addEventListener('click', handleDeleteAll); // Add handler
  1094. footer.appendChild(deleteAllBtn);
  1095. manager.appendChild(footer); // Add footer after list
  1096.  
  1097. // --- Populate List ---
  1098. const timestamps = await getTimestamps(currentVideoId);
  1099. if (!timestamps.length) {
  1100. checkManagerEmpty(true, list); // Show empty message
  1101. deleteAllBtn.disabled = true; // Disable delete all if no timestamps
  1102. } else {
  1103. timestamps.forEach(ts => {
  1104. const item = createTimestampItem(ts);
  1105. list.appendChild(item);
  1106. });
  1107. deleteAllBtn.disabled = false;
  1108. }
  1109.  
  1110. // --- Position and Display ---
  1111. positionManager();
  1112. document.body.appendChild(manager);
  1113.  
  1114. // --- Global Listeners for Closing ---
  1115. // Add listeners AFTER manager is in DOM and initial setup is done
  1116. setTimeout(() => {
  1117. document.addEventListener('keydown', managerKeydownHandler);
  1118. document.addEventListener('click', managerOutsideClickHandler, true); // Capture phase
  1119. }, 0);
  1120. }
  1121.  
  1122. // --- Manager Helper Functions ---
  1123.  
  1124. function closeManager() {
  1125. if (manager) {
  1126. manager.remove();
  1127. manager = null;
  1128. // Remove global listeners when manager closes
  1129. document.removeEventListener('keydown', managerKeydownHandler);
  1130. document.removeEventListener('click', managerOutsideClickHandler, true);
  1131. }
  1132. }
  1133.  
  1134. // Specific handler for manager keydown events
  1135. function managerKeydownHandler(e) {
  1136. if (e.key === 'Escape') {
  1137. // Check if an input field inside the manager has focus
  1138. const activeElement = document.activeElement;
  1139. const isInputFocused = manager && manager.contains(activeElement) && activeElement.tagName === 'INPUT';
  1140.  
  1141. if (!isInputFocused) { // Only close manager if not editing text
  1142. closeManager();
  1143. } else {
  1144. // If input is focused, let Escape blur the input first (handled in item creation)
  1145. activeElement.blur();
  1146. }
  1147. }
  1148. }
  1149.  
  1150. // Specific handler for clicks outside the manager
  1151. function managerOutsideClickHandler(e) {
  1152. // Close only if click is outside manager and not on the 'M' button that opened it
  1153. // AND also check if the click is inside a confirmation dialog
  1154. const isInsideConfirmation = !!e.target.closest('.tanuki-confirmation');
  1155. if (manager && !manager.contains(e.target) && !e.target.closest('.tanuki-button[title="Manage timestamps"]') && !isInsideConfirmation) {
  1156. closeManager();
  1157. }
  1158. }
  1159.  
  1160. function positionManager() {
  1161. if (!manager) return;
  1162. const video = document.querySelector('video');
  1163. if (video) {
  1164. const videoRect = video.getBoundingClientRect();
  1165. const managerWidth = 540; // Match CSS
  1166. const managerHeight = 380; // Match CSS
  1167. // Calculate centered position, ensuring it stays within viewport bounds
  1168. let left = videoRect.left + (videoRect.width - managerWidth) / 2;
  1169. let top = videoRect.top + (videoRect.height - managerHeight) / 2;
  1170.  
  1171. left = Math.max(10, Math.min(window.innerWidth - managerWidth - 10, left));
  1172. top = Math.max(10, Math.min(window.innerHeight - managerHeight - 10, top));
  1173.  
  1174. manager.style.left = `${left}px`;
  1175. manager.style.top = `${top}px`;
  1176. manager.style.transform = ''; // Reset transform if previously used
  1177. } else { // Fallback positioning (centered in viewport)
  1178. manager.style.position = 'fixed';
  1179. manager.style.top = '50%';
  1180. manager.style.left = '50%';
  1181. manager.style.transform = 'translate(-50%, -50%)';
  1182. }
  1183. }
  1184.  
  1185.  
  1186. // Helper to check if manager list is empty and show/hide message
  1187. function checkManagerEmpty(forceShow = null, list = null) {
  1188. // If manager is gone, don't try to access its children
  1189. if (!manager && !list) {
  1190. // console.log("checkManagerEmpty: No manager or list provided.");
  1191. return;
  1192. }
  1193.  
  1194. // Prefer passed list, fallback to querying manager IF it still exists
  1195. const theList = list || manager?.querySelector('.tanuki-manager-list');
  1196. const deleteAllBtn = manager?.querySelector('.delete-all-btn');
  1197.  
  1198. // Check if theList itself exists now
  1199. if (!theList) {
  1200. // This case can happen if the manager was removed concurrently, e.g., during handleDeleteAll
  1201. // console.warn("checkManagerEmpty: Target list element not found.");
  1202. return;
  1203. }
  1204.  
  1205.  
  1206. const emptyMsgClass = 'tanuki-empty-msg';
  1207. let emptyMsg = theList.querySelector(`.${emptyMsgClass}`);
  1208. // Check for items *within* theList element
  1209. const hasItems = !!theList.querySelector('.tanuki-timestamp-item');
  1210.  
  1211. if (forceShow === true || (forceShow === null && !hasItems)) {
  1212. if (!emptyMsg) {
  1213. emptyMsg = document.createElement('div');
  1214. emptyMsg.className = emptyMsgClass;
  1215. emptyMsg.textContent = 'No timestamps created for this video yet.'; // Updated message
  1216. theList.prepend(emptyMsg); // Add message at the top
  1217. }
  1218. if (deleteAllBtn) deleteAllBtn.disabled = true; // Disable delete all button
  1219. } else if (forceShow === false || (forceShow === null && hasItems)) {
  1220. if (emptyMsg) {
  1221. emptyMsg.remove();
  1222. }
  1223. if (deleteAllBtn) deleteAllBtn.disabled = false; // Enable delete all button
  1224. }
  1225. }
  1226.  
  1227. // --- Handle Delete All ---
  1228. async function handleDeleteAll() {
  1229. if (!currentVideoId || !manager) return;
  1230.  
  1231. // Get manager elements *before* confirmation/await
  1232. const listElement = manager.querySelector('.tanuki-manager-list');
  1233. const deleteAllButton = manager.querySelector('.delete-all-btn');
  1234. if (!listElement) {
  1235. console.error("Tanuki Timestamp: Manager list element not found in handleDeleteAll.");
  1236. return; // Should not happen if manager exists, but safety check
  1237. }
  1238.  
  1239. const timestamps = await getTimestamps(currentVideoId);
  1240. if (timestamps.length === 0) {
  1241. showNotification("No timestamps to delete.");
  1242. return;
  1243. }
  1244.  
  1245. const confirmed = await showConfirmation(`Are you sure you want to delete all ${timestamps.length} timestamps for this video? This cannot be undone.`);
  1246.  
  1247. // Check if manager still exists after confirmation await
  1248. if (!manager) {
  1249. console.log("Tanuki Timestamp: Manager was closed during confirmation.");
  1250. return;
  1251. }
  1252. // Re-verify listElement still belongs to the current manager
  1253. if (!manager.contains(listElement)) {
  1254. console.error("Tanuki Timestamp: Stale list element reference in handleDeleteAll after confirmation.");
  1255. return;
  1256. }
  1257.  
  1258. if (confirmed) {
  1259. console.log("Tanuki Timestamp: Deleting all timestamps for video:", currentVideoId);
  1260. try {
  1261. // Create an array of delete promises
  1262. const deletePromises = timestamps.map(ts => deleteTimestamp(currentVideoId, ts.time));
  1263. // Wait for all deletions to complete
  1264. await Promise.all(deletePromises);
  1265. console.log("Tanuki Timestamp: Database deletions complete.");
  1266.  
  1267. // Clear UI *after* DB operations
  1268. // Replace innerHTML setting with safe node removal
  1269. while (listElement.firstChild) {
  1270. listElement.removeChild(listElement.firstChild);
  1271. }
  1272. console.log("Tanuki Timestamp: Manager list cleared safely.");
  1273.  
  1274. removeProgressMarkers(); // <<<< Call marker removal
  1275. console.log("Tanuki Timestamp: Progress markers removed.");
  1276.  
  1277. // Update manager state using the list reference
  1278. checkManagerEmpty(true, listElement); // Show empty message and disable button
  1279. console.log("Tanuki Timestamp: Manager state updated (empty).");
  1280.  
  1281. showNotification("All timestamps deleted successfully.");
  1282.  
  1283. } catch (error) {
  1284. console.error("Tanuki Timestamp: Error deleting all timestamps:", error);
  1285. showNotification("Error occurred while deleting timestamps.");
  1286. // Attempt to restore sensible state if possible
  1287. if (deleteAllButton) deleteAllButton.disabled = timestamps.length === 0;
  1288. }
  1289. } else {
  1290. console.log("Tanuki Timestamp: Delete all cancelled.");
  1291. }
  1292. }
  1293.  
  1294.  
  1295. // --- Manager Timestamp Item Creation & Editing --- (Inline Editing Logic)
  1296. function createTimestampItem(ts) {
  1297. const item = document.createElement('div');
  1298. item.className = 'tanuki-timestamp-item';
  1299. item.dataset.time = ts.time; // Store time for easy access
  1300.  
  1301. const timeEl = document.createElement('span');
  1302. timeEl.textContent = formatTime(ts.time);
  1303. timeEl.title = 'Double-click to edit time';
  1304.  
  1305. const noteEl = document.createElement('span');
  1306. if (ts.note) {
  1307. noteEl.textContent = ts.note;
  1308. } else {
  1309. noteEl.textContent = NOTE_PLACEHOLDER;
  1310. noteEl.classList.add('tanuki-note-placeholder');
  1311. }
  1312. noteEl.title = 'Double-click to edit note';
  1313.  
  1314. const goBtn = document.createElement('button');
  1315. goBtn.textContent = '▶'; // Using a play symbol
  1316. goBtn.title = 'Go to timestamp';
  1317. goBtn.className = 'go-btn';
  1318.  
  1319. const deleteBtn = document.createElement('button');
  1320. deleteBtn.textContent = '✕'; // Using a cross symbol
  1321. deleteBtn.title = 'Delete timestamp';
  1322. deleteBtn.className = 'delete-btn';
  1323.  
  1324. // Create a container for the buttons for better layout control if needed
  1325. const buttonContainer = document.createElement('div');
  1326. buttonContainer.style.display = 'flex'; // Keep buttons inline
  1327. buttonContainer.style.alignItems = 'center';
  1328. buttonContainer.append(goBtn, deleteBtn);
  1329.  
  1330. item.append(timeEl, noteEl, buttonContainer); // Add button container
  1331.  
  1332.  
  1333. // --- Event Listeners ---
  1334.  
  1335. // Go to time
  1336. goBtn.addEventListener('click', () => {
  1337. const video = document.querySelector('video');
  1338. if (video) {
  1339. video.currentTime = ts.time;
  1340. // Optional: Close manager after clicking Go
  1341. // closeManager();
  1342. }
  1343. });
  1344.  
  1345. // Delete Single Item
  1346. deleteBtn.addEventListener('click', async () => {
  1347. // Find the item again in case `ts` object is stale (unlikely here, but good practice)
  1348. const currentItemTime = parseInt(item.dataset.time);
  1349. const confirmed = await showConfirmation(`Delete timestamp at ${formatTime(currentItemTime)}?`);
  1350. if (confirmed) {
  1351. await deleteTimestamp(currentVideoId, currentItemTime);
  1352. removeMarker(currentItemTime); // Remove from progress bar
  1353. item.remove();
  1354. checkManagerEmpty(); // Check if list is now empty after deletion
  1355. }
  1356. });
  1357.  
  1358. // --- Inline Editing Functions ---
  1359. const makeEditable = (element, inputClass, originalValue, saveCallback, validationCallback = null) => {
  1360. // Check if the *parent* of the element is currently showing an input
  1361. if (element.parentNode && element.parentNode.querySelector('input')) return;
  1362.  
  1363. const input = document.createElement('input');
  1364. input.type = 'text';
  1365. input.className = inputClass;
  1366. input.value = originalValue;
  1367.  
  1368. // Store reference to the original element being replaced
  1369. const originalElement = element;
  1370. originalElement.replaceWith(input);
  1371. input.focus();
  1372. input.select();
  1373.  
  1374. let isSaving = false; // Flag to prevent concurrent saves on blur/enter
  1375.  
  1376. const saveChanges = async () => {
  1377. // If input is no longer in the DOM (e.g., parent removed), exit
  1378. if (!input.parentNode) {
  1379. // console.log("Tanuki Timestamp: Input parent node missing, aborting save.");
  1380. return false;
  1381. }
  1382. if (isSaving) return false; // Prevent re-entry
  1383. isSaving = true;
  1384.  
  1385. const newValue = input.value.trim();
  1386.  
  1387. // Validation
  1388. if (validationCallback && !(await validationCallback(newValue))) {
  1389. input.replaceWith(originalElement); // Revert on invalid input
  1390. isSaving = false;
  1391. return false; // Indicate save failed
  1392. }
  1393.  
  1394. // Check if value actually changed
  1395. const originalTimeSeconds = (inputClass === 'time-input') ? parseTime(originalElement.textContent) : null;
  1396. const hasChanged = (inputClass === 'time-input')
  1397. ? parseTime(newValue) !== originalTimeSeconds // Compare parsed seconds
  1398. : newValue !== (ts.note || ''); // Compare trimmed string note
  1399.  
  1400.  
  1401. if (hasChanged) {
  1402. try {
  1403. // Pass input & original element to callback, await its completion
  1404. await saveCallback(newValue, input, originalElement);
  1405. // Callback is now responsible for replacing input with originalElement
  1406. } catch (error) {
  1407. console.error("Tanuki Timestamp: Error during save callback:", error);
  1408. // Ensure replacement happens even on error in callback
  1409. if (input.parentNode) input.replaceWith(originalElement);
  1410. }
  1411. } else {
  1412. // Only replace if input is still in DOM
  1413. if (input.parentNode) input.replaceWith(originalElement);
  1414. }
  1415. isSaving = false;
  1416. return true; // Indicate save (or revert due to no change) succeeded
  1417. };
  1418.  
  1419. const handleBlur = async (e) => {
  1420. // Small delay to allow clicking other buttons within the item if needed
  1421. // Check if focus moved to another element within the *same item*
  1422. const relatedTarget = e.relatedTarget;
  1423. // Only save if focus moves outside the item, or to something non-interactive inside
  1424. if (!relatedTarget || !item.contains(relatedTarget) || !['BUTTON', 'INPUT', 'A'].includes(relatedTarget.tagName)) {
  1425. await saveChanges();
  1426. }
  1427. };
  1428.  
  1429. input.addEventListener('blur', (e) => setTimeout(() => handleBlur(e), 150)); // Increased delay slightly
  1430.  
  1431. input.addEventListener('keydown', async (e) => {
  1432. if (e.key === 'Enter') {
  1433. e.preventDefault();
  1434. await saveChanges();
  1435. } else if (e.key === 'Escape') {
  1436. e.preventDefault();
  1437. // Check if input is still in DOM before replacing
  1438. if (input.parentNode) {
  1439. input.replaceWith(originalElement); // Cancel edit on Escape
  1440. }
  1441. }
  1442. });
  1443. };
  1444.  
  1445. // Edit Time (Double Click)
  1446. timeEl.addEventListener('dblclick', () => {
  1447. makeEditable(timeEl, 'time-input', timeEl.textContent,
  1448. async (newTimeString, inputElement, originalDisplayElement) => { // saveCallback
  1449. const newTime = parseTime(newTimeString);
  1450. const oldTime = ts.time;
  1451.  
  1452. // Update DB: Delete old, add new
  1453. await deleteTimestamp(currentVideoId, oldTime);
  1454. await saveTimestamp(currentVideoId, newTime, ts.note);
  1455.  
  1456. // Update internal state and UI element
  1457. ts.time = newTime;
  1458. item.dataset.time = newTime; // Update item's data attribute
  1459. originalDisplayElement.textContent = formatTime(newTime); // Update the original span's text
  1460. if (inputElement.parentNode) inputElement.replaceWith(originalDisplayElement); // Put the original span back
  1461. updateMarker(oldTime, newTime, ts.note); // Update progress marker
  1462.  
  1463. // Re-sort items in the manager list visually
  1464. const list = manager?.querySelector('.tanuki-manager-list');
  1465. if(list) {
  1466. const items = Array.from(list.querySelectorAll('.tanuki-timestamp-item'));
  1467. items.sort((a, b) => parseInt(a.dataset.time) - parseInt(b.dataset.time));
  1468. items.forEach(sortedItem => list.appendChild(sortedItem)); // Re-append in sorted order
  1469. }
  1470. showNotification(`Time updated to ${formatTime(newTime)}`);
  1471. },
  1472. async (newTimeString) => { // validationCallback (async)
  1473. const newTime = parseTime(newTimeString);
  1474. if (newTime === null || newTime < 0) {
  1475. showNotification('Invalid time format (HH:MM:SS)');
  1476. return false;
  1477. }
  1478. // Check if time already exists (and it's not the original time)
  1479. if (newTime !== ts.time) {
  1480. const existingTimestamps = await getTimestamps(currentVideoId);
  1481. if (existingTimestamps.some(t => t.time === newTime)) {
  1482. showNotification(`Timestamp at ${formatTime(newTime)} already exists.`);
  1483. return false;
  1484. }
  1485. }
  1486. return true; // Validation passed
  1487. }
  1488. );
  1489. });
  1490.  
  1491. // Edit Note (Double Click)
  1492. noteEl.addEventListener('dblclick', () => {
  1493. makeEditable(noteEl, 'note-input', ts.note || '', // Use actual note or empty string if placeholder
  1494. async (newNote, inputElement, originalDisplayElement) => { // saveCallback
  1495. await saveTimestamp(currentVideoId, ts.time, newNote);
  1496.  
  1497. // Update internal state and UI element
  1498. ts.note = newNote;
  1499. if (newNote) {
  1500. originalDisplayElement.textContent = newNote;
  1501. originalDisplayElement.classList.remove('tanuki-note-placeholder');
  1502. } else {
  1503. originalDisplayElement.textContent = NOTE_PLACEHOLDER;
  1504. originalDisplayElement.classList.add('tanuki-note-placeholder');
  1505. }
  1506. // Replace input with the updated original element
  1507. if (inputElement.parentNode) inputElement.replaceWith(originalDisplayElement);
  1508. updateMarker(ts.time, ts.time, newNote); // Update progress marker tooltip info
  1509. showNotification(`Note updated for ${formatTime(ts.time)}`);
  1510. }
  1511. // No specific validation needed for notes other than trimming which happens in saveChanges
  1512. );
  1513. });
  1514.  
  1515.  
  1516. return item;
  1517. }
  1518.  
  1519. // --- Update existing item in manager (used after adding/saving via Note Input) --- (No changes needed)
  1520. function updateTimestampItem(itemElement, ts) {
  1521. if (!itemElement) return;
  1522.  
  1523. const timeEl = itemElement.querySelector('span:first-child');
  1524. const noteEl = itemElement.querySelector('span:nth-child(2)');
  1525.  
  1526. if (timeEl) timeEl.textContent = formatTime(ts.time);
  1527. if (noteEl) {
  1528. if (ts.note) {
  1529. noteEl.textContent = ts.note;
  1530. noteEl.classList.remove('tanuki-note-placeholder');
  1531. } else {
  1532. noteEl.textContent = NOTE_PLACEHOLDER;
  1533. noteEl.classList.add('tanuki-note-placeholder');
  1534. }
  1535. }
  1536. itemElement.dataset.time = ts.time; // Ensure data attribute is updated
  1537. }
  1538.  
  1539.  
  1540. // --- Initialization and Video Change Detection ---
  1541. let initInterval = setInterval(() => {
  1542. const videoId = getCurrentVideoId();
  1543. const videoPlayer = document.querySelector('video');
  1544. const controlsExist = !!document.querySelector('.ytp-left-controls'); // Check if controls are loaded
  1545.  
  1546. // Wait for video metadata (readyState >= 1) and controls
  1547. if (videoId && videoPlayer && videoPlayer.readyState >= 1 && controlsExist) {
  1548. if (videoId !== currentVideoId) {
  1549. // Video changed or first load for this video ID
  1550. // console.log("Tanuki Timestamp: Video detected/changed - ", videoId);
  1551. cleanupUI(); // Clean up previous UI if any
  1552. currentVideoId = videoId;
  1553. // Use timeout to ensure player is fully ready for UI injection
  1554. setTimeout(setupUI, 500);
  1555. } else if (!uiContainer && currentVideoId === videoId) {
  1556. // If video ID is the same but UI isn't there (e.g., navigating back/forth quickly, or initial load race condition)
  1557. // console.log("Tanuki Timestamp: Re-initializing UI for ", videoId);
  1558. cleanupUI(); // Clean up just in case parts exist
  1559. setTimeout(setupUI, 500); // Attempt to setup UI again
  1560. }
  1561. } else if (currentVideoId && (!videoId || !videoPlayer || !controlsExist)) { // More robust check for leaving video
  1562. // Navigated away from a video page (or video element/controls removed)
  1563. // console.log("Tanuki Timestamp: Navigated away or video/controls lost, cleaning up.");
  1564. cleanupUI();
  1565. currentVideoId = null;
  1566. }
  1567. // Keep checking even if video not found initially, YT navigation might load it later
  1568. }, 1000); // Check every second
  1569.  
  1570. })();