您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
显示具体日期而不是“2 星期前”,“1 年前”这种相对日期。可自定义日期和时间格式。
// ==UserScript== // @version 0.7.1 // @name:en Display YouTube video upload dates as absolute dates (yyyy-mm-dd or other custom formats) // @name 以绝对时间显示 YouTube 的视频上传日期 (yyyy-mm-dd 或其他自定义格式) // @name:zh-TW 以絕對日期顯示 YouTube 的影片上傳日期 (yyyy-mm-dd 或其他自訂格式) // @name:ja-JP 絶対日付を YouTube の動画アップロード日付として表示します (yyyy-mm-dd または他のカスタム形式) // @description:en Show full upload dates, instead of "1 year ago", "2 weeks ago", etc. You can customize the date and time format. // @description 显示具体日期而不是“2 星期前”,“1 年前”这种相对日期。可自定义日期和时间格式。 // @description:zh-TW 顯示絕對日期,而不是「2 週前」、「1 年前」等相對日期。可自訂日期和時間格式。 // @description:ja-JP YouTubeの動画アップロード日付を「1 年前」などの相対的な表現ではなく、具体的な日付を表示します。表示形式をカスタマイズできます。 // @author InMirrors // @namespace https://greasyfork.org/users/518374 // @match https://www.youtube.com/* // @icon https://www.youtube.com/s/desktop/814d40a6/img/favicon_144x144.png // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @license MIT // ==/UserScript== (function () { 'use strict'; const PROCESSED_MARKER = '\u200B'; // Zero-Width Space const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd'; // Keywords for identifying relative date strings. const DATE_TIME_KEYWORDS_EN = 'second minute hour day week month year'; const DATE_TIME_KEYWORDS_ZH = '秒 分 时 時 天 日 周 週 月 年' const DEFAULT_DATE_TIME_KEYWORDS = [DATE_TIME_KEYWORDS_EN, DATE_TIME_KEYWORDS_ZH].join(' ').split(' '); const DEFAULT_AGO_KEYWORDS = ['ago', '前']; const DEFAULT_OLD_UPLOAD_KEYWORDS = ['day', 'week', 'month', 'year', '天', '日', '周', '週', '月', '年']; const DEFAULT_MONTH_NAMES = 'JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC'; const DEFAULT_DAY_NAMES = 'Sun Mon Tue Wed Thu Fri Sat'; // Get basic settings from storage const SETTINGS = GM_getValue("basic", {}); const DATE_FORMAT = SETTINGS.dateFormat || DEFAULT_DATE_FORMAT; const DATE_TIME_KEYWORDS = SETTINGS.dateTimeKeywords?.length ? SETTINGS.dateTimeKeywords : DEFAULT_DATE_TIME_KEYWORDS; const AGO_KEYWORDS = SETTINGS.agoKeywords?.length ? SETTINGS.agoKeywords : DEFAULT_AGO_KEYWORDS; const OLD_UPLOAD_KEYWORDS = SETTINGS.oldUploadKeywords?.length ? SETTINGS.oldUploadKeywords : DEFAULT_OLD_UPLOAD_KEYWORDS; const MONTH_NAMES = SETTINGS.monthNames?.length ? SETTINGS.monthNames : DEFAULT_MONTH_NAMES.split(' '); const DAY_NAMES = SETTINGS.dayNames?.length ? SETTINGS.dayNames : DEFAULT_DAY_NAMES.split(' '); const PREPEND_DATES_ENABLED = SETTINGS.prependDatesEnabled ?? false; // Get advanced settings from storage let timerModeEnabled = GM_getValue("timerModeEnabled", false); let fomattingTimer = null; // for timer mode let useAllConfigsEnabled = GM_getValue("useAllConfigsEnabled", false); // Test all configs to find valid ones for the current page let findValidConfigEnable = GM_getValue("findValidConfigEnable", false); // === Debug Mode === let debugModeEnabled = GM_getValue("debugModeEnabled", false); let debugVidsArrayLength = GM_getValue("debugVidsArrayLength", 4); if (debugModeEnabled) { GM_registerMenuCommand(`Toggle Debug mode ${debugModeEnabled ? "OFF" : "ON"}`, () => { debugModeEnabled = !debugModeEnabled; GM_setValue("debugModeEnabled", debugModeEnabled); alert(`Debug mode is now ${debugModeEnabled ? "ON" : "OFF"}`); }); GM_registerMenuCommand("Set vids array length", () => { let input = prompt("Please enter a number, 0 to disable slicing:", debugVidsArrayLength); if (input == null) { // input canceled return; } if (input.trim() !== "" && !isNaN(input)) { // valid input debugVidsArrayLength = Number(input); GM_setValue("debugVidsArrayLength", debugVidsArrayLength); alert("Value updated to: " + debugVidsArrayLength); } else { alert("Invalid input. Please enter a number."); } }); GM_registerMenuCommand(`Toggle all configs mode ${useAllConfigsEnabled ? "OFF" : "ON"}`, () => { useAllConfigsEnabled = !useAllConfigsEnabled; GM_setValue("useAllConfigsEnabled", useAllConfigsEnabled); alert(`All configs mode is now ${useAllConfigsEnabled ? "ON" : "OFF"}`); }); GM_registerMenuCommand(`Toggle all configs mode ${useAllConfigsEnabled ? "OFF" : "ON"}`, () => { useAllConfigsEnabled = !useAllConfigsEnabled; GM_setValue("useAllConfigsEnabled", useAllConfigsEnabled); alert(`All configs mode is now ${useAllConfigsEnabled ? "ON" : "OFF"}`); }); GM_registerMenuCommand(`Toggle find valid config mode ${findValidConfigEnable ? "OFF" : "ON"}`, () => { findValidConfigEnable = !findValidConfigEnable; if (findValidConfigEnable) { useAllConfigsEnabled = true; GM_setValue("useAllConfigsEnabled", useAllConfigsEnabled); } GM_setValue("findValidConfigEnable", findValidConfigEnable); alert(`Find valid config mode is now ${findValidConfigEnable ? "ON" : "OFF"}`); }); } /** * Validate configs array * @param {Array} configs - array of config objects * @returns {boolean} - true if all configs are valid, false otherwise */ function validateConfigs(configs) { const errors = []; configs.forEach(config => { // Get id and set a default value if necessary const { id = '[no id]' } = config; // Properties that must be non-empty strings const stringProps = [ "id", "videoContainerSelector", "metaSpansSelector", "vidLinkSelector", ]; // string properties must be non-empty strings stringProps.forEach(prop => { if (typeof config[prop] !== "string" || config[prop].trim() === "") { errors.push(`${id}: "${prop}" must be a non-empty string`); } }); // urlPattern must be RegExp if (!(config.urlPattern instanceof RegExp)) { errors.push(`${id}: "urlPattern" must be a RegExp`); } // shouldCreateDateSpan must be boolean if (typeof config.shouldCreateDateSpan !== "boolean") { errors.push(`${id}: "shouldCreateDateSpan" must be a boolean`); } // Conditional validation if (config.shouldCreateDateSpan === true) { if (typeof config.insertAfterIndex !== "number") { errors.push(`${id}: "insertAfterIndex" must be a number when shouldCreateDateSpan is true`); } } }); if (errors.length > 0) { console.log('[YTDF] Validation errors:'); errors.forEach(err => console.log(" - " + err)); return false; } else { console.log('[YTDF] All configs are valid!'); return true; } } // === Setting UI === const translations = { en: { menuTitle: 'Open Settings Panel', panelTitle: 'Settings', saveBtn: 'Save', resetBtn: 'Reset', languageLabel: 'Language', prependDatesLabel: 'Prepend dates', prependDatesHelp: 'Insert absolute dates before the original relative dates. Default is after.', dateFormatLabel: 'Date format', dateFormatHelp: 'The format for the displayed date. You can get syntax help from the script\'s readme.', oldUploadKeywordsLabel: 'Keywords for old uploads', oldUploadKeywordsHelp: 'Keywords to identify old uploads. Only formatted dates will be shown for these.', dateTimeKeywordsLabel: 'Date and time keywords *', dateTimeKeywordsHelp: 'Keywords used to identify relative date strings.', agoKeywordsLabel: 'Ago keyword *', agoKeywordsHelp: 'Keywords used to identify the "ago" part of relative dates, such as "ago" in "1 day ago".', monthNamesLabel: 'Month names', monthNamesHelp: 'The names of the months used in the date format (MMM).', dayNamesLabel: 'Day of the week names', dayNamesHelp: 'The names of the days of the week used in the date format (ww).', stringsFooterBasic: 'Click the ? icon to use the example.', stringsFooterI18n: 'You must complete these keywords settings if your YouTube language is not English, 中文 or 日本語.', helpTooltip: '{desc} Example:\n{example}', inputPlaceholder: 'Enter value...', alertSaved: 'Settings saved, please refresh the page to apply changes', confirmReset: 'Reset to defaults?' }, zh: { menuTitle: '打开设置面板', panelTitle: '设置', saveBtn: '保存', resetBtn: '重置', languageLabel: '语言', prependDatesLabel: '插入日期到前面', prependDatesHelp: '插入绝对日期到原始的相对日期前,默认在后面。', dateFormatLabel: '日期格式', dateFormatHelp: '显示日期的格式。详细语法请查阅脚本的帮助文档。', oldUploadKeywordsLabel: '旧视频关键字', oldUploadKeywordsHelp: '用于识别旧视频的关键字。匹配的视频只显示格式化后的日期,不显示原始文本。', dateTimeKeywordsLabel: '日期和时间关键字 *', dateTimeKeywordsHelp: '用于识别相对日期字符串的关键字。', agoKeywordsLabel: '“前”的关键字 *', agoKeywordsHelp: '相对日期中的关键字,例如 "1天前" 中的 "前"。', monthNamesLabel: '月份名称', monthNamesHelp: '日期格式中 "MMM" 对应的月份名称。', dayNamesLabel: '周几的名称', dayNamesHelp: '日期格式中 "ww" 对应的周几的名称。', stringsFooterBasic: '点击 ? 图标以使用示例', stringsFooterI18n: '中文用户不用管下面的。', helpTooltip: '{desc}示例:\n{example}', inputPlaceholder: '请输入...', alertSaved: '设置已保存,请刷新页面以应用更改', confirmReset: '要重置为默认值吗?' } }; let currentStrings = (() => { let currentLang = SETTINGS.language || 'en'; return translations[currentLang] || translations.en; })(); const panelCSS = ` :root { --ytdf-panel-bg:#fff; --ytdf-accent:#1e88e5; } #ytdf-panel { width: 520px; margin: 40px auto; border-radius: 10px; background: var(--ytdf-panel-bg); box-shadow: 0 6px 24px rgba(0,0,0,0.12); font-family: Arial, sans-serif; color: black; overflow: hidden; position: fixed; top: 50px; left: 50%; transform: translateX(-50%); z-index: 9999; display: none; /* Initially hidden */ } .ytdf-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid #eef2f7; } .ytdf-header h1 { font-size:18px; font-weight:bolder; margin:0; } .ytdf-header .ytdf-buttons { display:flex; gap:8px; } .ytdf-button { background:var(--ytdf-accent); color:#fff; border:none; padding:8px 12px; border-radius:7px; cursor:pointer; } .ytdf-button.ytdf-secondary { background:#edf2f7; color:#111; border:1px solid #d1dae8; } .ytdf-container { padding:18px; display:grid; gap:14px; } .ytdf-section { padding:12px; border-radius:8px; box-shadow: 0px 0px 6px lightgray; } .ytdf-section-title { font-size:18px; font-weight:bolder; margin-block:10px; } .ytdf-section.ytdf-selects { background:hsl(110,60%,99%); border-left:4px solid hsl(110,50%,70%); } .ytdf-section.ytdf-switches { background:hsl(190,60%,99%); border-left:4px solid hsl(190,50%,70%); } .ytdf-section.ytdf-strings-basic { background:hsl(250,60%,99%); border-left:4px solid hsl(250,70%,70%); } .ytdf-section.ytdf-strings-i18n { background:hsl(340,60%,99%); border-left:4px solid hsl(340,70%,70%); } .ytdf-switch-row { display:flex; align-items:center; gap:10px; padding:6px 0; } .ytdf-switch-row label { flex:1; display:flex; align-items:center; gap:8px; font-size:14px; } .ytdf-toggle { width:40px; height:22px; background:#cbd5e1; border-radius:20px; position:relative; cursor:pointer; flex:0 0 auto; } .ytdf-toggle .ytdf-knob { position:absolute; top:2px; left:2px; width:18px; height:18px; background:white; border-radius:50%; transition:all 0.18s; } .ytdf-toggle.ytdf-on { background:#4fbe79; } .ytdf-toggle.ytdf-on .ytdf-knob { left:20px; } .ytdf-string-label-help, .ytdf-select-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-top:1px dashed rgba(0,0,0,0.04); } .ytdf-string-row:first-child, .ytdf-select-row:first-child { border-top:none; } .ytdf-select-label { width:140px; font-size:14px; } .ytdf-string-label { font-size:14px; } .ytdf-string-input, .ytdf-select-input { flex:1; } .ytdf-string-input input[type="text"], .ytdf-select-input select { width:100%; padding:8px; border-radius:6px; border:1px solid #cbd5e1; } .ytdf-help { margin-left:auto; cursor:pointer; user-select:none; font-size:12px; color:#0366d6; } .ytdf-footer-note { font-size:12px; color:#718096; margin-top:6px; } @media (max-width:600px) { #ytdf-panel { width:92%; top: 20px; left: 4%; transform: none; } } `; // Inject HTML and CSS into the page GM_addStyle(panelCSS); // Function to create the panel element using DOM manipulation // Use this approach to bypass YouTube's TrustedHTML restriction function createPanelElement() { const panel = document.createElement('div'); panel.id = 'ytdf-panel'; // Header const header = document.createElement('div'); header.className = 'ytdf-header'; const h1 = document.createElement('h1'); h1.dataset.langKey = 'panelTitle'; h1.textContent = 'Settings'; const buttonsDiv = document.createElement('div'); buttonsDiv.className = 'ytdf-buttons'; const resetButton = document.createElement('button'); resetButton.className = 'ytdf-button ytdf-secondary'; resetButton.id = 'ytdf-reset-btn'; resetButton.dataset.langKey = 'resetBtn'; resetButton.textContent = 'Reset'; const saveButton = document.createElement('button'); saveButton.className = 'ytdf-button'; saveButton.id = 'ytdf-save-btn'; saveButton.dataset.langKey = 'saveBtn'; saveButton.textContent = 'Save'; buttonsDiv.appendChild(resetButton); buttonsDiv.appendChild(saveButton); header.appendChild(h1); header.appendChild(buttonsDiv); panel.appendChild(header); // Container const container = document.createElement('div'); container.className = 'ytdf-container'; // Section: Selects (Language) const selectsSection = document.createElement('div'); selectsSection.className = 'ytdf-section ytdf-selects'; const selectRow = document.createElement('div'); selectRow.className = 'ytdf-select-row'; const selectLabel = document.createElement('div'); selectLabel.className = 'ytdf-select-label'; selectLabel.dataset.langKey = 'languageLabel'; selectLabel.textContent = 'Language'; const selectInput = document.createElement('div'); selectInput.className = 'ytdf-select-input'; const langSelect = document.createElement('select'); langSelect.id = 'ytdf-lang-select'; const optionEn = document.createElement('option'); optionEn.value = 'en'; optionEn.textContent = 'English'; const optionZh = document.createElement('option'); optionZh.value = 'zh'; optionZh.textContent = '中文'; langSelect.appendChild(optionEn); langSelect.appendChild(optionZh); selectInput.appendChild(langSelect); selectRow.appendChild(selectLabel); selectRow.appendChild(selectInput); selectsSection.appendChild(selectRow); // Section: Switches (Prepend dates) const switchesSection = document.createElement('div'); switchesSection.className = 'ytdf-section ytdf-switches'; const switchRow = document.createElement('div'); switchRow.className = 'ytdf-switch-row'; const label = document.createElement('label'); const toggleDiv = document.createElement('div'); toggleDiv.className = 'ytdf-toggle'; toggleDiv.dataset.key = 'prependDatesEnabled'; toggleDiv.setAttribute('role', 'switch'); toggleDiv.setAttribute('tabindex', '0'); const knobDiv = document.createElement('div'); knobDiv.className = 'ytdf-knob'; toggleDiv.appendChild(knobDiv); const spanPrependDates = document.createElement('span'); spanPrependDates.dataset.langKey = 'prependDatesLabel'; spanPrependDates.textContent = 'Prepend dates'; const helpDiv = document.createElement('div'); helpDiv.className = 'ytdf-help'; helpDiv.dataset.langKey = 'prependDatesHelp'; helpDiv.dataset.desc = 'Add absolute dates before or after the original dates.'; helpDiv.textContent = '❓'; label.appendChild(toggleDiv); label.appendChild(spanPrependDates); label.appendChild(helpDiv); switchRow.appendChild(label); switchesSection.appendChild(switchRow); // Section: Strings Basic (Date format, Old upload keywords) const stringsBasicSection = document.createElement('div'); stringsBasicSection.className = 'ytdf-section ytdf-strings-basic'; const footerNoteBasic = document.createElement('div'); footerNoteBasic.className = 'ytdf-footer-note'; footerNoteBasic.dataset.langKey = 'stringsFooterBasic'; footerNoteBasic.textContent = 'Click the ? icon to use the example.'; stringsBasicSection.appendChild(footerNoteBasic); // Date format row const dateFormatRow = document.createElement('div'); dateFormatRow.className = 'ytdf-string-row'; dateFormatRow.dataset.key = 'dateFormat'; const dateFormatLabelHelp = document.createElement('div'); dateFormatLabelHelp.className = 'ytdf-string-label-help'; const dateFormatLabel = document.createElement('div'); dateFormatLabel.className = 'ytdf-string-label'; dateFormatLabel.dataset.langKey = 'dateFormatLabel'; dateFormatLabel.textContent = 'Date format'; const dateFormatHelp = document.createElement('div'); dateFormatHelp.className = 'ytdf-help'; dateFormatHelp.dataset.langKey = 'dateFormatHelp'; dateFormatHelp.dataset.example = 'yyyy-MM-dd'; dateFormatHelp.dataset.desc = 'Date format help'; dateFormatHelp.textContent = '❓'; dateFormatLabelHelp.appendChild(dateFormatLabel); dateFormatLabelHelp.appendChild(dateFormatHelp); const dateFormatInputDiv = document.createElement('div'); dateFormatInputDiv.className = 'ytdf-string-input'; const dateFormatInput = document.createElement('input'); dateFormatInput.type = 'text'; dateFormatInput.id = 'ytdf-dateFormat'; dateFormatInput.setAttribute('placeholder', ''); dateFormatInputDiv.appendChild(dateFormatInput); dateFormatRow.appendChild(dateFormatLabelHelp); dateFormatRow.appendChild(dateFormatInputDiv); stringsBasicSection.appendChild(dateFormatRow); // Old upload keywords row const oldUploadKeywordsRow = document.createElement('div'); oldUploadKeywordsRow.className = 'ytdf-string-row'; oldUploadKeywordsRow.dataset.key = 'oldUploadKeywords'; const oldUploadKeywordsLabelHelp = document.createElement('div'); oldUploadKeywordsLabelHelp.className = 'ytdf-string-label-help'; const oldUploadKeywordsLabel = document.createElement('div'); oldUploadKeywordsLabel.className = 'ytdf-string-label'; oldUploadKeywordsLabel.dataset.langKey = 'oldUploadKeywordsLabel'; oldUploadKeywordsLabel.textContent = 'Old upload keywords'; const oldUploadKeywordsHelp = document.createElement('div'); oldUploadKeywordsHelp.className = 'ytdf-help'; oldUploadKeywordsHelp.dataset.langKey = 'oldUploadKeywordsHelp'; oldUploadKeywordsHelp.dataset.example = 'day week month year'; oldUploadKeywordsHelp.dataset.desc = 'Old upload keywords help'; oldUploadKeywordsHelp.textContent = '❓'; oldUploadKeywordsLabelHelp.appendChild(oldUploadKeywordsLabel); oldUploadKeywordsLabelHelp.appendChild(oldUploadKeywordsHelp); const oldUploadKeywordsInputDiv = document.createElement('div'); oldUploadKeywordsInputDiv.className = 'ytdf-string-input'; const oldUploadKeywordsInput = document.createElement('input'); oldUploadKeywordsInput.type = 'text'; oldUploadKeywordsInput.id = 'ytdf-oldUploadKeywords'; oldUploadKeywordsInput.setAttribute('placeholder', ''); oldUploadKeywordsInputDiv.appendChild(oldUploadKeywordsInput); oldUploadKeywordsRow.appendChild(oldUploadKeywordsLabelHelp); oldUploadKeywordsRow.appendChild(oldUploadKeywordsInputDiv); stringsBasicSection.appendChild(oldUploadKeywordsRow); // Section: Strings I18n (Date and time keywords, Ago keywords, Month names) const stringsI18nSection = document.createElement('div'); stringsI18nSection.className = 'ytdf-section ytdf-strings-i18n'; const footerNoteI18n = document.createElement('div'); footerNoteI18n.className = 'ytdf-footer-note'; footerNoteI18n.dataset.langKey = 'stringsFooterI18n'; footerNoteI18n.textContent = 'You must complete these two keywords settings if your YouTube language is not English, 中文 or 日本語.'; stringsI18nSection.appendChild(footerNoteI18n); // Date and time keywords row const dateTimeKeywordsRow = document.createElement('div'); dateTimeKeywordsRow.className = 'ytdf-string-row'; dateTimeKeywordsRow.dataset.key = 'dateTimeKeywords'; const dateTimeKeywordsLabelHelp = document.createElement('div'); dateTimeKeywordsLabelHelp.className = 'ytdf-string-label-help'; const dateTimeKeywordsLabel = document.createElement('div'); dateTimeKeywordsLabel.className = 'ytdf-string-label'; dateTimeKeywordsLabel.dataset.langKey = 'dateTimeKeywordsLabel'; dateTimeKeywordsLabel.textContent = 'Date and time keywords'; const dateTimeKeywordsHelp = document.createElement('div'); dateTimeKeywordsHelp.className = 'ytdf-help'; dateTimeKeywordsHelp.dataset.langKey = 'dateTimeKeywordsHelp'; dateTimeKeywordsHelp.dataset.example = 'second minute hour day week month year'; dateTimeKeywordsHelp.dataset.desc = 'Date time keywords help'; dateTimeKeywordsHelp.textContent = '❓'; dateTimeKeywordsLabelHelp.appendChild(dateTimeKeywordsLabel); dateTimeKeywordsLabelHelp.appendChild(dateTimeKeywordsHelp); const dateTimeKeywordsInputDiv = document.createElement('div'); dateTimeKeywordsInputDiv.className = 'ytdf-string-input'; const dateTimeKeywordsInput = document.createElement('input'); dateTimeKeywordsInput.type = 'text'; dateTimeKeywordsInput.id = 'ytdf-dateTimeKeywords'; dateTimeKeywordsInput.setAttribute('placeholder', ''); dateTimeKeywordsInputDiv.appendChild(dateTimeKeywordsInput); dateTimeKeywordsRow.appendChild(dateTimeKeywordsLabelHelp); dateTimeKeywordsRow.appendChild(dateTimeKeywordsInputDiv); stringsI18nSection.appendChild(dateTimeKeywordsRow); // Ago keywords row const agoKeywordsRow = document.createElement('div'); agoKeywordsRow.className = 'ytdf-string-row'; agoKeywordsRow.dataset.key = 'agoKeywords'; const agoKeywordsLabelHelp = document.createElement('div'); agoKeywordsLabelHelp.className = 'ytdf-string-label-help'; const agoKeywordsLabel = document.createElement('div'); agoKeywordsLabel.className = 'ytdf-string-label'; agoKeywordsLabel.dataset.langKey = 'agoKeywordsLabel'; agoKeywordsLabel.textContent = 'Ago keyword'; const agoKeywordsHelp = document.createElement('div'); agoKeywordsHelp.className = 'ytdf-help'; agoKeywordsHelp.dataset.langKey = 'agoKeywordsHelp'; agoKeywordsHelp.dataset.example = 'ago'; agoKeywordsHelp.dataset.desc = 'Ago keyword help'; agoKeywordsHelp.textContent = '❓'; agoKeywordsLabelHelp.appendChild(agoKeywordsLabel); agoKeywordsLabelHelp.appendChild(agoKeywordsHelp); const agoKeywordsInputDiv = document.createElement('div'); agoKeywordsInputDiv.className = 'ytdf-string-input'; const agoKeywordsInput = document.createElement('input'); agoKeywordsInput.type = 'text'; agoKeywordsInput.id = 'ytdf-agoKeywords'; agoKeywordsInput.setAttribute('placeholder', ''); agoKeywordsInputDiv.appendChild(agoKeywordsInput); agoKeywordsRow.appendChild(agoKeywordsLabelHelp); agoKeywordsRow.appendChild(agoKeywordsInputDiv); stringsI18nSection.appendChild(agoKeywordsRow); // Month names row const monthNamesRow = document.createElement('div'); monthNamesRow.className = 'ytdf-string-row'; monthNamesRow.dataset.key = 'monthNames'; const monthNamesLabelHelp = document.createElement('div'); monthNamesLabelHelp.className = 'ytdf-string-label-help'; const monthNamesLabel = document.createElement('div'); monthNamesLabel.className = 'ytdf-string-label'; monthNamesLabel.dataset.langKey = 'monthNamesLabel'; monthNamesLabel.textContent = 'Month names'; const monthNamesHelp = document.createElement('div'); monthNamesHelp.className = 'ytdf-help'; monthNamesHelp.dataset.langKey = 'monthNamesHelp'; monthNamesHelp.dataset.example = 'JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC'; monthNamesHelp.dataset.desc = 'Month names help'; monthNamesHelp.textContent = '❓'; monthNamesLabelHelp.appendChild(monthNamesLabel); monthNamesLabelHelp.appendChild(monthNamesHelp); const monthNamesInputDiv = document.createElement('div'); monthNamesInputDiv.className = 'ytdf-string-input'; const monthNamesInput = document.createElement('input'); monthNamesInput.type = 'text'; monthNamesInput.id = 'ytdf-monthNames'; monthNamesInput.setAttribute('placeholder', ''); monthNamesInputDiv.appendChild(monthNamesInput); monthNamesRow.appendChild(monthNamesLabelHelp); monthNamesRow.appendChild(monthNamesInputDiv); stringsI18nSection.appendChild(monthNamesRow); // Day names row const dayNamesRow = document.createElement('div'); dayNamesRow.className = 'ytdf-string-row'; dayNamesRow.dataset.key = 'dayNames'; const dayNamesLabelHelp = document.createElement('div'); dayNamesLabelHelp.className = 'ytdf-string-label-help'; const dayNamesLabel = document.createElement('div'); dayNamesLabel.className = 'ytdf-string-label'; dayNamesLabel.dataset.langKey = 'dayNamesLabel'; dayNamesLabel.textContent = 'Day names'; const dayNamesHelp = document.createElement('div'); dayNamesHelp.className = 'ytdf-help'; dayNamesHelp.dataset.langKey = 'dayNamesHelp'; dayNamesHelp.dataset.example = 'Sun Mon Tue Wed Thu Fri Sat'; dayNamesHelp.dataset.desc = 'Day names help'; dayNamesHelp.textContent = '❓'; dayNamesLabelHelp.appendChild(dayNamesLabel); dayNamesLabelHelp.appendChild(dayNamesHelp); const dayNamesInputDiv = document.createElement('div'); dayNamesInputDiv.className = 'ytdf-string-input'; const dayNamesInput = document.createElement('input'); dayNamesInput.type = 'text'; dayNamesInput.id = 'ytdf-dayNames'; dayNamesInput.setAttribute('placeholder', ''); dayNamesInputDiv.appendChild(dayNamesInput); dayNamesRow.appendChild(dayNamesLabelHelp); dayNamesRow.appendChild(dayNamesInputDiv); stringsI18nSection.appendChild(dayNamesRow); container.appendChild(selectsSection); container.appendChild(switchesSection); container.appendChild(stringsBasicSection); container.appendChild(stringsI18nSection); panel.appendChild(container); return panel; } const panelElementToInsert = createPanelElement(); if (panelElementToInsert) { document.body.appendChild(panelElementToInsert); } else { console.error('[YTDF] Failed to create panel element.'); } // === Setting UI Logic === // Corresponding runtime values are constants at the top of the file // Only local settings stored in the GM storage are modified by the UI const panelElement = document.getElementById('ytdf-panel'); function saveSettings(obj) { GM_setValue("basic", obj); } // Update all text in the UI based on the selected language function updateUIText(lang) { currentStrings = translations[lang] || translations.en; document.querySelectorAll('[data-lang-key]').forEach(el => { const key = el.dataset.langKey; if (currentStrings[key]) { // Help elements if (el.dataset.desc) { el.dataset.desc = currentStrings[key]; } else { el.innerText = currentStrings[key]; } } }); // Update placeholders and titles which are not covered by the generic loop document.querySelectorAll('.ytdf-string-input input').forEach(input => { input.setAttribute('placeholder', currentStrings.inputPlaceholder); }); attachHelpHandlers(); // Re-attach to update tooltips } function setToggle(el, value) { if (value) { el.classList.add('ytdf-on'); } else { el.classList.remove('ytdf-on'); } el.setAttribute('aria-checked', !!value); } function applySettingsToUI(settings) { const langSelect = document.getElementById('ytdf-lang-select'); langSelect.value = settings.language || 'en'; updateUIText(langSelect.value); const arrayToStr = (array) => Array.isArray(array) ? array.join(' ') : ''; setToggle(document.querySelector('.ytdf-toggle[data-key="prependDatesEnabled"]'), settings.prependDatesEnabled); document.getElementById('ytdf-dateFormat').value = settings.dateFormat || ''; document.getElementById('ytdf-oldUploadKeywords').value = arrayToStr(settings.oldUploadKeywords); document.getElementById('ytdf-dateTimeKeywords').value = arrayToStr(settings.dateTimeKeywords); document.getElementById('ytdf-agoKeywords').value = arrayToStr(settings.agoKeywords); document.getElementById('ytdf-monthNames').value = arrayToStr(settings.monthNames); document.getElementById('ytdf-dayNames').value = arrayToStr(settings.dayNames); } function readUIToSettings() { const stringToArray = (str) => str.split(' ').filter(Boolean); return { language: document.getElementById('ytdf-lang-select').value, prependDatesEnabled: document.querySelector('.ytdf-toggle[data-key="prependDatesEnabled"]').classList.contains('ytdf-on'), dateFormat: document.getElementById('ytdf-dateFormat').value, oldUploadKeywords: stringToArray(document.getElementById('ytdf-oldUploadKeywords').value), dateTimeKeywords: stringToArray(document.getElementById('ytdf-dateTimeKeywords').value), agoKeywords: stringToArray(document.getElementById('ytdf-agoKeywords').value), monthNames: stringToArray(document.getElementById('ytdf-monthNames').value), dayNames: stringToArray(document.getElementById('ytdf-dayNames').value), }; } function attachToggleHandlers() { document.querySelectorAll('.ytdf-toggle').forEach(t => { t.addEventListener('click', () => { const newState = !t.classList.contains('ytdf-on'); setToggle(t, newState); }); t.addEventListener('keydown', (ev) => { if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); t.click(); } }); }); } function attachHelpHandlers() { document.querySelectorAll('.ytdf-string-row, .ytdf-switch-row').forEach(row => { const help = row.querySelector('.ytdf-help'); if (!help) return; const example = help.dataset.example || ''; const desc = help.dataset.desc || ''; const key = row.dataset.key; // Switch rows if (!key) { help.setAttribute('title', desc); return; } const titleText = currentStrings.helpTooltip .replace('{desc}', desc) .replace('{example}', example); help.setAttribute('title', titleText); if (example) { help.addEventListener('click', () => { const input = document.getElementById('ytdf-' + key); if (input) { input.value = example; input.focus(); } }); } }); } // --- Panel Visibility Controls --- function showPanel() { if (panelElement) panelElement.style.display = 'block'; } function hidePanel() { if (panelElement) panelElement.style.display = 'none'; } GM_registerMenuCommand(currentStrings.menuTitle, showPanel); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { hidePanel(); } }); document.addEventListener('click', (e) => { // Hide only if the panel is visible and the click is outside the panel if (panelElement.style.display === 'block' && !panelElement.contains(e.target)) { // And also ensure the click is not on a Tampermonkey menu item if (!e.target.closest('.GM-style-mt')) { hidePanel(); } } }, true); // Use capture to catch the click early // --- Handlers --- // save button document.getElementById('ytdf-save-btn').addEventListener('click', () => { saveSettings(readUIToSettings()); alert(currentStrings.alertSaved); }); // reset button document.getElementById('ytdf-reset-btn').addEventListener('click', () => { if (!confirm(currentStrings.confirmReset)) return; // Saving default values, but keeping the language setting const currentLang = document.getElementById('ytdf-lang-select').value; const settingsToSave = { language: currentLang }; saveSettings(settingsToSave); alert(currentStrings.alertSaved); applySettingsToUI(settingsToSave); }); // language dropdown document.getElementById('ytdf-lang-select').addEventListener('change', (e) => { updateUIText(e.target.value); }); // --- Initialize the UI --- try { applySettingsToUI(SETTINGS); attachToggleHandlers(); } catch (e) { console.error('[YTDF] Error initializing UI:', e); } // === Date Formatting === /** * Find elements in a NodeList containing any of the specified keywords. * * @param {NodeList | Array} nodeList - A NodeList or array of elements to search. * @param {string[]} keywords - Array of keywords to search for in element textContent. * @param {boolean} [findAll=false] - If true, return all matches; if false, return the first match. * @returns {Element | Element[] | undefined} - A single element, an array of elements, or undefined if no match. */ function findElementsByKeywords(nodeList, keywords, findAll = false) { // Convert NodeList to a real Array (if it isn't already) const elements = Array.from(nodeList); if (findAll) { // Return all matching elements as an array return elements.filter(el => keywords.some(keyword => el.textContent.includes(keyword)) ); } else { // Return only the first matching element return elements.find(el => keywords.some(keyword => el.textContent.includes(keyword)) ); } } /** * Format a date (string, number, or Date) into a string with a custom template. * * @param {string|number|Date} date - Date string, timestamp, or Date object * @param {string} [template="yyyy-MM-dd HH:mm:ss"] - Template string (e.g., "yyyy-MM-dd HH:mm:ss") * @param {boolean} [useLocal=true] - Whether to use local time (true) or UTC (false) * @param {string[]} [months] - Uppercase month names (default: English JAN-DEC) * @param {string[]} [days] - Day names (default: English Sun-Sat) * @returns {string} Formatted date string, or "" if invalid * * Supported tokens: * - yyyy: 4-digit year * - yy: 2-digit year * - MMM: uppercase month name from given array * - MM: 2-digit month * - dd: 2-digit day * - ww: day of week * - HH: 24-hour format (00-23) * - hh: 12-hour format (01-12) * - ap: AM/PM * - mm: 2-digit minutes * - ss: 2-digit seconds */ function getDateStr(date, template = "yyyy-MM-dd HH:mm:ss", useLocal = true, months, days) { const defaultMonths = [ "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" ]; const defaultDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const monthNames = months || defaultMonths; const dayNames = days || defaultDays; const dt = new Date(date); // If invalid date, return empty string if (isNaN(dt.getTime())) { return ""; } // Get local or UTC info automatically const getMethod = (key) => useLocal ? dt[`get${key}`]() : dt[`getUTC${key}`](); const pad = (num, size = 2) => String(num).padStart(size, "0"); const map = { yyyy: String(getMethod("FullYear")), yy: String(getMethod("FullYear")).slice(-2), MMM: monthNames[getMethod("Month")], MM: pad(getMethod("Month") + 1), dd: pad(getMethod("Date")), ww: dayNames[getMethod("Day")], HH: pad(getMethod("Hours")), hh: pad((getMethod("Hours") % 12) || 12), ap: getMethod("Hours") < 12 ? "AM" : "PM", mm: pad(getMethod("Minutes")), ss: pad(getMethod("Seconds")), }; return template.replaceAll(/(yy(yy)?|MMM?|dd|ww|HH|hh|mm|ss|ap)/g, n => map[n]); } function getUploadDate() { let el = document.body.querySelector('player-microformat-renderer script'); if (el) { let parts = el.textContent.split('"startDate":"', 2); if (parts.length == 2) { return parts[1].split('"', 1)[0]; } parts = el.textContent.split('"uploadDate":"', 2); if (parts.length == 2) { return parts[1].split('"', 1)[0]; } } return null; } // Check if the video is a live broadcast function getIsLiveBroadcast() { let el = document.body.querySelector('player-microformat-renderer script'); if (!el) { return null; } let parts = el.textContent.split('"isLiveBroadcast":', 2); if (parts.length != 2) { return false; } let isLiveBroadcast = !!parts[1].split(',', 1)[0]; if (!isLiveBroadcast) { return false; } parts = el.textContent.split('"endDate":"', 2); if (parts.length == 2) { return false; } return true; } // Update the upload date in the video description function processDescription() { let uploadDate = getUploadDate(); if (uploadDate) { // Format the date and check if it's a live broadcast uploadDate = getDateStr(uploadDate, DATE_FORMAT, true, MONTH_NAMES, DAY_NAMES); let isLiveBroadcast = getIsLiveBroadcast(); if (isLiveBroadcast) { document.body.classList.add('ytud-description-live'); } else { document.body.classList.remove('ytud-description-live'); } // Update the upload date in the video description let el = document.querySelector('#info-container > #info > b'); if (!el) { let span = document.querySelector('#info-container > #info > span:nth-child(1)'); if (span) { el = document.createElement('b'); el.textContent = uploadDate; span.insertAdjacentElement('afterend', el); } } else { if (el.parentNode.children[1] !== el) { let container = el.parentNode; el = container.removeChild(el); container.children[0].insertAdjacentElement('afterend', el); } if (el.firstChild.nodeValue !== uploadDate) { el.firstChild.nodeValue = uploadDate; } } } } // This section for the topic sidebar is too different and is kept separate. /* search list - topic in sidebar */ function processTopicSidebar() { let vids = document.querySelectorAll('#contents > ytd-universal-watch-card-renderer > #sections > ytd-watch-card-section-sequence-renderer > #lists > ytd-vertical-watch-card-list-renderer > #items > ytd-watch-card-compact-video-renderer'); if (vids.length > 0) { vids.forEach((el) => { let holders = el.querySelectorAll('div.text-wrapper > yt-formatted-string.subtitle'); if (holders.length === 0) { return; } let holder = holders[0]; let separator = ' • '; let parts = holder.firstChild.nodeValue.split(separator, 2); if (parts.length < 2) { return; } let dateText = parts[1]; let text = el.getAttribute('date-text'); if (text !== null && text === dateText) { return; } el.setAttribute('date-text', dateText); let link = el.querySelector('a#thumbnail').getAttribute('href'); let videoId = urlToVideoId(link); fetchAndUpdateUploadDate(videoId, holder, dateText); }); } } // Extract video id from the URL function urlToVideoId(url) { let parts = url.split('/shorts/', 2); if (parts.length === 2) { url = parts[1]; } else { url = parts[0]; } parts = url.split('v=', 2); if (parts.length === 2) { url = parts[1]; } else { url = parts[0]; } return url.split('&', 1)[0]; } // Retrieve the upload date from a remote source using the video id and invoke the callback with the result function getRemoteUploadDate(videoId, callback) { let body = { "context": { "client": { "clientName": "WEB", "clientVersion": "2.20240416.01.00" } }, "videoId": videoId }; fetch('https://www.youtube.com/youtubei/v1/player?prettyPrint=false', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(response => { if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(data => { let object = data.microformat.playerMicroformatRenderer; if (object.liveBroadcastDetails?.isLiveNow) { callback(object.liveBroadcastDetails.startTimestamp); return; } else if (object.publishDate) { callback(object.publishDate); return; } callback(object.uploadDate); }) .catch(error => { console.error('[YTDF] There was a problem with the fetch operation:', error); }); } /** * Process and update upload date info for a video element * @param {string} videoId - YouTube video ID * @param {HTMLElement} dateElem - DOM element that holds the date * @param {string} originalDateText - The original relative time text */ function fetchAndUpdateUploadDate(videoId, dateElem, originalDateText) { getRemoteUploadDate(videoId, (uploadDate) => { const formattedDate = getDateStr(uploadDate, DATE_FORMAT, true, MONTH_NAMES, DAY_NAMES) + PROCESSED_MARKER; let displayText; // Show only formatted date for old uploads if (OLD_UPLOAD_KEYWORDS.some(keyword => originalDateText.includes(keyword))) { displayText = formattedDate; } // Keep original + formatted date for recent uploads else { if (PREPEND_DATES_ENABLED) { displayText = `${formattedDate} · ${originalDateText}`; } else { displayText = `${originalDateText} · ${formattedDate}`; } } dateElem.firstChild.nodeValue = displayText; }); } /** * Finds and processes video elements on the page based on a given configuration. * This function queries for video containers, extracts metadata, * and updates the date information. * @param {object} config - Configuration for a specific type of video list. * @param {string} config.id - Identifier for the configuration. * @param {RegExp} config.urlPattern - A regular expression to test against the current URL. * @param {string} config.videoContainerSelector - CSS selector for the video container elements. * @param {string} config.metaSpansSelector - CSS selector for metadata spans within the video container. * @param {string} config.vidLinkSelector - CSS selector for the video link element. * @param {boolean} config.shouldCreateDateSpan - If a new date span needs to be created. * @param {number} config.insertAfterIndex - Index at which to insert the new date span. */ function findAndProcessVids(config) { // Skip when current address does not match the pattern if (config.urlPattern && !config.urlPattern.test(window.location.href) && !useAllConfigsEnabled) { return; } let vids = document.querySelectorAll(config.videoContainerSelector); if (vids.length === 0) { // if (debugModeEnabled) console.warn(`[YTDF] No vids found for [${config.id}]`); return; // No videos found for this config, just return. } // Only process some elements to avoid excessive logging in debug mode. if (debugModeEnabled && debugVidsArrayLength != 0 && vids.length > 1) { vids = Array.from(vids).slice(0, debugVidsArrayLength); } vids.forEach((vidContainer) => { const metaSpans = vidContainer.querySelectorAll(config.metaSpansSelector); if (metaSpans.length === 0) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] No metaSpan found for [${config.id}]`); return; } let dateSpan; // Check if a new date span needs to be created. if (config.shouldCreateDateSpan) { dateSpan = document.createElement('span'); dateSpan.className = 'inline-metadata-item style-scope ytd-video-meta-block ytdf-date'; dateSpan.appendChild(document.createTextNode('')); metaSpans[config.insertAfterIndex].insertAdjacentElement('afterend', dateSpan); } else { // Find the date span by looking for keywords. const spansEndWithAgo = Array.from(metaSpans).filter(span => AGO_KEYWORDS.some(ago => span.textContent.includes(ago)) ) dateSpan = findElementsByKeywords(spansEndWithAgo, DATE_TIME_KEYWORDS); } if (!dateSpan) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] dateSpan is null for [${config.id}]`); return; } const dateText = dateSpan.textContent; if (!dateText) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] dateText is null for [${config.id}]`); return; } // Skip if already processed. if (dateText.includes(PROCESSED_MARKER)) { return; } // Mark as processed by adding an invisible marker character. // Using textContent or innerText may cause issues. // For example, on a channel's video page, switching between sorting methods might not update the dates. dateSpan.firstChild.nodeValue = dateText + PROCESSED_MARKER; // Find the video link element to extract the video ID. const vidLinkElem = vidContainer.querySelector(config.vidLinkSelector); if (!vidLinkElem) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] No vidLinkElem found for [${config.id}]`); return; } const vidLink = vidLinkElem.getAttribute('href'); if (!vidLink) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] vidLink is null for [${config.id}]`); return; } const videoId = urlToVideoId(vidLink); if (!videoId) { if (debugModeEnabled && !findValidConfigEnable) console.warn(`[YTDF] videoId is null for [${config.id}]`); return; } if (findValidConfigEnable) { console.warn(`[YTDF] [${config.id}] is valid`); } fetchAndUpdateUploadDate(videoId, dateSpan, dateText); }); } // Configuration array for different video list types. const configs = [ { id: 'Video Page Sidebar', urlPattern: /watch\?v=/, videoContainerSelector: 'yt-lockup-view-model.lockup', metaSpansSelector: '.yt-core-attributed-string--link-inherit-color', vidLinkSelector: '.yt-lockup-view-model__content-image', shouldCreateDateSpan: false, }, { id: 'Homepage Videos', urlPattern: /www\.youtube\.com\/?$/, videoContainerSelector: 'ytd-rich-item-renderer.style-scope.ytd-rich-grid-renderer', metaSpansSelector: '.yt-core-attributed-string--link-inherit-color', vidLinkSelector: '.yt-lockup-view-model__content-image', shouldCreateDateSpan: false, }, { id: 'Homepage Shorts', urlPattern: /XXXwww\.youtube\.com\/?$/, // remove XXX to enable this config videoContainerSelector: 'dummy', metaSpansSelector: '#metadata-line > span', vidLinkSelector: '.yt-lockup-view-model__content-image', shouldCreateDateSpan: true, insertAfterIndex: 0, }, { id: 'Search List Videos', urlPattern: /results\?search_query=/, videoContainerSelector: 'ytd-video-renderer.ytd-item-section-renderer', metaSpansSelector: '.inline-metadata-item', vidLinkSelector: '#thumbnail', shouldCreateDateSpan: false, }, { id: 'Search List Shorts', urlPattern: /XXXresults\?search_query=/, videoContainerSelector: 'dummy', metaSpansSelector: '#metadata-line > span', vidLinkSelector: '.yt-lockup-view-model__content-image', shouldCreateDateSpan: true, insertAfterIndex: 0, }, { id: 'Subscriptions', urlPattern: /subscriptions/, videoContainerSelector: '#dismissible', metaSpansSelector: '#metadata-line > span', vidLinkSelector: 'h3 > a', shouldCreateDateSpan: false, }, { id: 'Channel Videos', urlPattern: /www.youtube.com\/[^\/]+?\/videos/, videoContainerSelector: 'ytd-rich-grid-media.ytd-rich-item-renderer', metaSpansSelector: '#metadata-line > span', vidLinkSelector: 'h3 > a', shouldCreateDateSpan: false, }, { id: 'Channel Featured Videos', // Some channel addresses don't include an "@" symbol. // Lack a suitable RegEx to match these addresses without incorrectly matching others. // The current RegEx only matches regular channel addresses. urlPattern: /www.youtube.com\/@[^\/]+?\/?(featured)?$/, videoContainerSelector: 'ytd-grid-video-renderer.yt-horizontal-list-renderer', metaSpansSelector: '#metadata-line > span', vidLinkSelector: 'a#thumbnail', shouldCreateDateSpan: false, }, { id: 'Channel For You Videos', urlPattern: /www.youtube.com\/@[^\/]+?\/?(featured)?$/, videoContainerSelector: 'ytd-channel-video-player-renderer.ytd-item-section-renderer', metaSpansSelector: '#metadata-line > span', vidLinkSelector: '#title a', shouldCreateDateSpan: false, }, { id: 'Video Playlist', urlPattern: /playlist\?list=/, videoContainerSelector: 'ytd-playlist-video-renderer.ytd-playlist-video-list-renderer', metaSpansSelector: 'span.yt-formatted-string', vidLinkSelector: 'a#thumbnail', shouldCreateDateSpan: false, } ]; // === Date Formatting Launching === // Run all formatters function runAllFormatters() { try { if (debugModeEnabled) { if (!validateConfigs(configs)) { return; } } processDescription(); processTopicSidebar(); // Process all video lists configs.forEach(findAndProcessVids); } catch (error) { console.error('[YTDF] Error running formatters:', error); } } // Get all video container selectors from the configs array let validConfigs = []; function updateSelectors() { const currentUrl = window.location.href; const newConfigs = configs.filter(config => config.urlPattern.test(currentUrl)); if (newConfigs !== validConfigs) { validConfigs = newConfigs; if (debugModeEnabled) { console.log('[YTDF] Valid configs:'); console.log(validConfigs); } } } function updateFormattingTimer() { if (fomattingTimer !== null) { clearInterval(fomattingTimer); } fomattingTimer = setInterval(() => { runAllFormatters(); }, 1000) } // Dates on the subscriptions page aren't updated promptly using the observer // Don't know why. So add a timer to handle it let subscriptionsPageTimer = null; const subscriptionsPagePattern = /subscriptions/; function handleSubscriptionsPageTimer() { if (subscriptionsPagePattern.test(window.location.href)) { subscriptionsPageTimer = setInterval(() => { runAllFormatters(); }, 1000); } else if (subscriptionsPageTimer !== null) { clearInterval(subscriptionsPageTimer); subscriptionsPageTimer = null; } } window.addEventListener('yt-navigate-finish', () => { updateSelectors() handleSubscriptionsPageTimer(); }); // Run once on script load for the initial page content. updateSelectors(); runAllFormatters(); // Use a MutationObserver to detect page changes const observer = new MutationObserver((mutationsList, observer) => { let observerConfigs = useAllConfigsEnabled ? configs : validConfigs; if (observerConfigs.length === 0) { if (debugModeEnabled) { console.warn('[YTDF] No valid configs found, skipping observer...'); } return; } // handleSubscriptionsPageTimer() handles the subscriptions page, just return if (subscriptionsPagePattern.test(window.location.href)) { return; } let shouldRunFormatters = false; const videoContainerSelectors = observerConfigs.flatMap(config => config.videoContainerSelector); const videoContainerSelectorsStr = videoContainerSelectors .filter(selector => selector && typeof selector === 'string') // filter out invalid ones .join(', '); for (const mutation of mutationsList) { // Condition 1: New elements added to the DOM (e.g., infinite scroll) if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { // Node is an element // Check if the added node is a video card itself if (node.matches(videoContainerSelectorsStr)) { shouldRunFormatters = true; } else { // Check if the added node contains a video card if (node.querySelector(videoContainerSelectorsStr)) { shouldRunFormatters = true; } } } }); } // Condition 2: Attributes change on existing elements // Only switching sorting methods on the channel videos page meets this condition // This handles cases like a video card's info being replaced if (mutation.type === 'attributes') { const videoLinkSelector = configs.find(config => config.id === 'Channel Videos').vidLinkSelector; // Check if the mutation target is a video link if (mutation.target.matches(videoLinkSelector)) { // If the link's href changed, we need to run the formatters if (mutation.oldValue !== mutation.target.href) { shouldRunFormatters = true; } } } } if (shouldRunFormatters) { // Use a debounce or a slight delay to prevent running too frequently clearTimeout(window.formatterDebounce); window.formatterDebounce = setTimeout(() => { runAllFormatters(); }, 500); // Wait 500ms before running } }); if (!timerModeEnabled) { // Start observing the entire document body observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] // Only observe href attribute changes }); } else { // Start a timer on page load updateFormattingTimer(); } let styleTag = document.createElement('style'); let cssCode = "#info > span:nth-child(3) {display:none !important;}" + "#info > span:nth-child(4) {display:none !important;}" + "#info > b {font-weight:500 !important;margin-left:6px !important;}" + "#date-text {display:none !important;}" + ".ytud-description-live #info > span:nth-child(1) {display:none !important;}" + ".ytud-description-live #info > b {margin-left:0 !important;margin-right:6px !important;}"; styleTag.textContent = cssCode; document.head.appendChild(styleTag); })();