YouTube Timestamp Manager

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

目前为 2025-04-23 提交的版本。查看 最新版本

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