YouTube Timestamp Saver (Optimized UI)

Save YouTube timestamps with simplified UI, exportable CSV, and persistent storage

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Timestamp Saver (Optimized UI)
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Save YouTube timestamps with simplified UI, exportable CSV, and persistent storage
// @author       You
// @match        https://www.youtube.com/watch*
// @match        https://www.youtube.com/shorts/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  const timeFields = [
    ['Start', 'startTime'],
    ['End', 'endTime'],
    ['Query', 'queryTime'],
    ['TgtStart', 'targetStartTime'],
    ['TgtEnd', 'targetEndTime'],
    ['TgtStart2', 'targetStart2'],
    ['TgtEnd2', 'targetEnd2'],
    ['TgtStart3', 'targetStart3'],
    ['TgtEnd3', 'targetEnd3']
  ];

  let label = '', videoURL = location.href.split('&')[0];
  let values = Object.fromEntries(timeFields.map(([_, k]) => [k, '']));
  let savedRows = JSON.parse(localStorage.getItem('yt_savedRows') || '[]');

  const saveToLocal = () => localStorage.setItem('yt_savedRows', JSON.stringify(savedRows));

  const formatTime = (s) => {
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = Math.floor(s % 60);
    const ms = Math.round((s % 1) * 1000);
    const formatted = `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
    return h > 0 ? `${h}:${formatted}` : formatted;
  };

  const parseTime = (str) => {
    const p = str.split(/[:.]/).map(Number);
    return p.length === 4 ? p[0]*3600 + p[1]*60 + p[2] + p[3]/1000 : p[0]*60 + p[1] + p[2]/1000;
  };

  const getTime = () => {
    const v = document.querySelector('video');
    return v ? formatTime(v.currentTime) : '';
  };

  const seekTo = (key) => {
    const v = document.querySelector('video');
    const t = parseTime(inputs[key].value);
    if (v && !isNaN(t)) v.currentTime = t;
  };

  const setTime = (key) => {
    values[key] = getTime();
    inputs[key].value = values[key];
  };

  const saveRow = () => {
    const row = [videoURL, label, ...timeFields.map(([_, k]) => values[k])];
    savedRows.push(row);
    saveToLocal();
    preview.textContent = savedRows.map(r => r.join('\t')).join('\n');
    labelInput.value = '';
    timeFields.forEach(([_, k]) => inputs[k].value = values[k] = '');
  };

  const downloadCSV = () => {
    const header = ['videoURL', 'label', ...timeFields.map(([_, k]) => k)];
    const csv = [header, ...savedRows].map(r => r.join(',')).join('\n');
    const blob = new Blob([csv], { type: 'text/csv' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'timestamps.csv';
    a.click();
    savedRows = [];
    localStorage.removeItem('yt_savedRows');
    preview.textContent = '';
  };

  // Create UI
  const bar = document.createElement('div');
  bar.style.cssText = `position:fixed;bottom:0;left:0;width:100%;background:#fff;padding:5px 8px;display:flex;flex-wrap:wrap;gap:4px;font:12px sans-serif;z-index:99999;box-shadow:0 -1px 6px rgba(0,0,0,0.1)`;

  const inputs = {};

  const labelInput = document.createElement('input');
  labelInput.placeholder = 'Label';
  labelInput.style.width = '80px';
  labelInput.oninput = () => label = labelInput.value;
  bar.appendChild(labelInput);

  timeFields.forEach(([labelText, key]) => {
    const input = document.createElement('input');
    input.placeholder = labelText;
    input.style.width = '70px';
    inputs[key] = input;
    bar.appendChild(input);
    bar.appendChild(Object.assign(document.createElement('button'), {
      textContent: 'Set', onclick: () => setTime(key), style: 'font-size:11px'
    }));
    bar.appendChild(Object.assign(document.createElement('button'), {
      textContent: 'Go', onclick: () => seekTo(key), style: 'font-size:11px'
    }));
  });

  bar.appendChild(Object.assign(document.createElement('button'), {
    textContent: 'Save Row', onclick: saveRow, style: 'background:#4caf50;color:#fff;padding:3px 8px'
  }));

  bar.appendChild(Object.assign(document.createElement('button'), {
    textContent: 'Download CSV', onclick: downloadCSV, style: 'padding:3px 8px'
  }));

  const preview = document.createElement('div');
  preview.style.cssText = 'white-space:pre;overflow:auto;max-height:60px;width:100%';
  bar.appendChild(preview);

  document.body.appendChild(bar);
  preview.textContent = savedRows.map(r => r.join('\t')).join('\n');

  // URL change detection
  let lastURL = videoURL;
  const observer = new MutationObserver(() => {
    const newURL = location.href.split('&')[0];
    if (newURL !== lastURL) {
      lastURL = videoURL = newURL;
      label = '';
      Object.keys(values).forEach(k => values[k] = '');
      timeFields.forEach(([_, k]) => inputs[k].value = '');
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });
})();